From 39cd2e7572d5f87cec8dc0005637fa9a29b0c088 Mon Sep 17 00:00:00 2001 From: dantp-ai <1534513+dantp-ai@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:32:48 +0200 Subject: [PATCH 1/2] =?UTF-8?q?refactor:=20replace=20monkey-patching=20wit?= =?UTF-8?q?h=20dependency=20injection=20=E2=80=94=20closes=20#64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `train()` now accepts keyword-only `make_llm_client` and `env_builders` parameters; the CLI dry-run path builds them as closures over the user's config and passes them in directly. This removes the module-level mutation of `clawloop.train._make_llm_client` and `ENV_BUILDERS` that PR #62 introduced, and the `_DryRunLLMClientConfig` subclass that PR #65 added to identify roles for the monkey-patched factory. Changes ------- - `clawloop/train.py`: - `train()` signature gains `*, make_llm_client=None, env_builders=None`; defaults preserve current behavior. - All six `_build_*` helpers gain `make_llm_client` as a third positional parameter (uniform signature). Only `_build_math` actually uses it today; others accept it for the future refactor tracked by the out-of-scope notes in the plan. - New module-level alias `LLMClientFactory = Callable[[LLMClientConfig], Any]` for readable type hints. - `clawloop/cli.py`: - Delete `_DryRunLLMClientConfig` and the `LLMClientConfig` import that only existed to subclass it. - Replace `_install_dry_run_clients` (which mutated module state) with `_build_dry_run_overrides(config)` (pure: returns `(factory, builders)`). - `cmd_run` passes the overrides into `train(...)` directly. - `_StubAdapter` and `ENVS_USING_MAKE_LLM_CLIENT` are unchanged. - Tests: - `tests/test_train_config.py::TestTrainEndToEnd::test_harness_learning_math`: drop `with patch("clawloop.train._make_llm_client")` and pass `make_llm_client=_pick_client` to `train()` directly. - `tests/test_cli.py`: - Remove the subclass round-trip test (subclass is gone). - Replace with `test_dry_run_overrides_map_roles_correctly` and `test_dry_run_overrides_stub_bypassing_env` exercising `_build_dry_run_overrides` directly. - Add `test_dry_run_does_not_mutate_module_state` — invokes `cmd_run` with `dry_run=True` and asserts `clawloop.train._make_llm_client` and `ENV_BUILDERS` are unchanged after the call. Locks in the no-global-mutation contract. - Keep `test_public_llm_client_config_schema_excludes_dry_run_vocab`. - `tests/unit/test_train.py`: builder invocations now pass `None` as the third positional arg (openspiel doesn't use it). Verification ------------ - pre-commit clean (ruff + ruff-format) - pytest tests/test_cli.py tests/test_train_config.py tests/unit/test_train.py → 68 pass - Full suite: 1046 passed, 49 skipped, 0 failed - `clawloop run examples/configs/math_harness.json --dry-run` exits 0 - `clawloop run examples/configs/taubench_harness.json --dry-run` with all API key env vars unset exits 0 - `clawloop eval` still exits 2 with the redirect Co-Authored-By: Claude Opus 4.7 --- clawloop/cli.py | 97 ++++++++++++------------------- clawloop/train.py | 70 ++++++++++++++++++----- tests/test_cli.py | 113 +++++++++++++++++++++++-------------- tests/test_train_config.py | 22 +++----- tests/unit/test_train.py | 4 +- 5 files changed, 172 insertions(+), 134 deletions(-) diff --git a/clawloop/cli.py b/clawloop/cli.py index 2e7bd8e3..53446341 100644 --- a/clawloop/cli.py +++ b/clawloop/cli.py @@ -18,32 +18,9 @@ from pathlib import Path from typing import Any -from clawloop.train import LLMClientConfig - log = logging.getLogger("clawloop") -class _DryRunLLMClientConfig(LLMClientConfig): - """Private subclass that tags an LLM client config with its dry-run role. - - Used only by ``cmd_run --dry-run``: ``_install_dry_run_clients`` swaps - each entry in ``config.llm_clients`` for an instance of this class, and - the patched ``_make_llm_client`` picks the right mock via ``isinstance``. - - Why a subclass instead of a field on ``LLMClientConfig``: - - The public ``LLMClientConfig`` schema stays free of testing - vocabulary — no ``dry_run_role: null`` in JSON dumps or generated - JSON Schema. - - ``model`` is left untouched, so downstream code that reads it - verbatim (e.g. ``_build_entropic`` propagating ``tc.model`` into - ``entropic_cfg``) is unaffected. - - Pydantic ``model_copy()`` preserves the runtime class, so the tag - survives copies just like a field would. - """ - - dry_run_role: str - - _EVAL_DISABLED_MSG = ( "`clawloop eval` is disabled. Use one of:\n" " - Real benchmark: uv run clawloop run examples/configs/math_harness.json\n" @@ -180,69 +157,65 @@ def cmd_run(args: argparse.Namespace) -> None: ) if args.dry_run: - _install_dry_run_clients(config) + factory, builders = _build_dry_run_overrides(config) + train(config, make_llm_client=factory, env_builders=builders) + else: + train(config) - train(config) +def _build_dry_run_overrides(config: Any) -> tuple[Any, dict[str, Any]]: + """Build the `(make_llm_client, env_builders)` overrides for `--dry-run`. -def _install_dry_run_clients(config: Any) -> None: - """Wire `--dry-run`: guarantee no real LLM calls regardless of env_type. + Pure function: returns the overrides without mutating any module-level + state. ``train()`` then accepts them as keyword arguments and uses them + in place of the global defaults. This replaces the earlier + monkey-patching approach (see PR #62, PR #65 history). Two parts: - 1. Replace each ``LLMClientConfig`` in ``config.llm_clients`` with a - ``_DryRunLLMClientConfig`` instance carrying the role, and patch - ``clawloop.train._make_llm_client`` to switch on ``isinstance``. - The private subclass keeps the public schema clean and survives - ``model_copy()``. - 2. For envs whose adapter bypasses ``_make_llm_client`` (per - ``train.ENVS_USING_MAKE_LLM_CLIENT``), swap the registered builder - with a stub that returns a no-I/O ``_StubAdapter``. + 1. A factory closure that maps each ``LLMClientConfig`` to the right + mock client based on its role in ``config.llm_clients``. The + closure looks up by object identity, which is safe here because + every builder receives ``config.llm_clients`` directly and indexes + by role name — no copies between cfg creation and factory call. + 2. For envs whose builder bypasses ``make_llm_client`` (per + ``train.ENVS_USING_MAKE_LLM_CLIENT``), an override entry whose + builder returns a no-I/O ``_StubAdapter``. """ import clawloop.train as _train from clawloop.demo_math import MockTaskClient, _build_mock_reflector_responses from clawloop.llm import MockLLMClient - # Part 1: replace each cfg with a subclass instance carrying the role, - # then route _make_llm_client through a mock factory that reads the role. - for role, cfg in list(config.llm_clients.items()): - config.llm_clients[role] = _DryRunLLMClientConfig(**cfg.model_dump(), dry_run_role=role) - - original_make = _train._make_llm_client - - def _mock_make(cfg): - if isinstance(cfg, _DryRunLLMClientConfig): - role = cfg.dry_run_role - if role == "reflector": - return MockLLMClient(responses=_build_mock_reflector_responses()) - if role == "task": - return MockTaskClient() - return MockLLMClient(responses=["[]"]) - return original_make(cfg) - - _train._make_llm_client = _mock_make - - # Part 2: for env_types that bypass _make_llm_client, replace the - # registered builder with one that returns a stub adapter. Without this, - # --dry-run on (e.g.) taubench / entropic / openclaw would still hit - # real endpoints. + role_by_id = {id(cfg): role for role, cfg in config.llm_clients.items()} + + def factory(cfg: Any) -> Any: + role = role_by_id.get(id(cfg)) + if role == "reflector": + return MockLLMClient(responses=_build_mock_reflector_responses()) + if role == "task": + return MockTaskClient() + return MockLLMClient(responses=["[]"]) + env_type = config.env_type uses_make_llm_client = env_type in _train.ENVS_USING_MAKE_LLM_CLIENT - if not uses_make_llm_client and env_type in _train.ENV_BUILDERS: + if uses_make_llm_client or env_type not in _train.ENV_BUILDERS: + builders: dict[str, Any] = _train.ENV_BUILDERS + else: # Floor at 1 to keep the task list non-empty even if a config sets # episodes_per_iter to 0; the learning loop samples from it. n_tasks = max(1, config.episodes_per_iter) stub_tasks = [f"dry_run_{env_type}_{i}" for i in range(n_tasks)] - def _stub_builder(_cfg: Any, _clients: Any) -> tuple[Any, list[str]]: + def stub_builder(_cfg: Any, _clients: Any, _factory: Any) -> tuple[Any, list[str]]: return _StubAdapter(env_type), list(stub_tasks) - _train.ENV_BUILDERS[env_type] = _stub_builder + builders = {**_train.ENV_BUILDERS, env_type: stub_builder} log.info( "dry-run: LLM clients mocked; env=%r %s", env_type, - "uses _make_llm_client" if uses_make_llm_client else "stubbed", + "uses make_llm_client" if uses_make_llm_client else "stubbed", ) + return factory, builders class _StubAdapter: diff --git a/clawloop/train.py b/clawloop/train.py index c1594fbd..52cae49c 100644 --- a/clawloop/train.py +++ b/clawloop/train.py @@ -5,17 +5,24 @@ harness_learning — prompt/playbook optimization via reflector LLM (no GPU) Environments are pluggable via ENV_BUILDERS registry. Each builder is a -function (config, llm_clients) -> (adapter, tasks) that constructs an -AdapterLike and a task list for the learning loop. +function ``(config, llm_clients, make_llm_client) -> (adapter, tasks)`` +that constructs an AdapterLike and a task list for the learning loop. + +``train()`` accepts ``make_llm_client`` and ``env_builders`` as keyword-only +parameters so callers (CLI dry-run, tests) can inject mocks without +mutating module-level state. """ from __future__ import annotations +from collections.abc import Callable from pathlib import Path from typing import Any, Literal from pydantic import BaseModel, SecretStr +LLMClientFactory = Callable[["LLMClientConfig"], Any] + # --------------------------------------------------------------------------- # Config # --------------------------------------------------------------------------- @@ -98,7 +105,11 @@ def _make_llm_client(cfg: LLMClientConfig): # --------------------------------------------------------------------------- -def _build_harbor(config: TrainConfig, llm_clients: dict[str, LLMClientConfig]): +def _build_harbor( + config: TrainConfig, + llm_clients: dict[str, LLMClientConfig], + make_llm_client: LLMClientFactory, +) -> tuple[Any, list[str]]: from clawloop.environments.harbor import HarborAdapter, HarborTaskEnvironment envs = [ @@ -112,16 +123,24 @@ def _build_harbor(config: TrainConfig, llm_clients: dict[str, LLMClientConfig]): return HarborAdapter(envs), [env.task_id for env in envs] -def _build_math(config: TrainConfig, llm_clients: dict[str, LLMClientConfig]): +def _build_math( + config: TrainConfig, + llm_clients: dict[str, LLMClientConfig], + make_llm_client: LLMClientFactory, +) -> tuple[Any, list[str]]: from clawloop.environments.math import MathAdapter, MathEnvironment - task_client = _make_llm_client(llm_clients["task"]) + task_client = make_llm_client(llm_clients["task"]) math_env = MathEnvironment() tasks = math_env.get_tasks() return MathAdapter(env=math_env, client=task_client), [s.question for s in tasks] -def _build_entropic(config: TrainConfig, llm_clients: dict[str, LLMClientConfig]): +def _build_entropic( + config: TrainConfig, + llm_clients: dict[str, LLMClientConfig], + make_llm_client: LLMClientFactory, +) -> tuple[Any, list[str]]: from clawloop.environments.entropic import EntropicAdapter entropic_cfg = dict(config.env_config or {}) @@ -141,7 +160,11 @@ def _build_entropic(config: TrainConfig, llm_clients: dict[str, LLMClientConfig] return adapter, [f"base_{i}" for i in range(n_tasks)] -def _build_openclaw(config: TrainConfig, llm_clients: dict[str, LLMClientConfig]) -> tuple: +def _build_openclaw( + config: TrainConfig, + llm_clients: dict[str, LLMClientConfig], + make_llm_client: LLMClientFactory, +) -> tuple[Any, list[str]]: from clawloop.environments.openclaw import OpenClawAdapter openclaw_cfg = dict(config.env_config or {}) @@ -170,7 +193,9 @@ def _build_openclaw(config: TrainConfig, llm_clients: dict[str, LLMClientConfig] def _build_taubench( - config: TrainConfig, llm_clients: dict[str, LLMClientConfig] + config: TrainConfig, + llm_clients: dict[str, LLMClientConfig], + make_llm_client: LLMClientFactory, ) -> tuple[Any, list[str]]: from clawloop.environments.taubench import TauBenchAdapter @@ -189,7 +214,11 @@ def _build_taubench( # --------------------------------------------------------------------------- -def _build_openspiel(config: "TrainConfig", llm_clients: dict[str, "LLMClientConfig"]): +def _build_openspiel( + config: "TrainConfig", + llm_clients: dict[str, "LLMClientConfig"], + make_llm_client: LLMClientFactory, +) -> tuple[Any, list[str]]: """Build a ClawLoop adapter over one or more OpenSpiel games. Two config shapes are accepted: @@ -425,12 +454,22 @@ def _positive_int(key: str) -> None: # --------------------------------------------------------------------------- -def train(config: TrainConfig): +def train( + config: TrainConfig, + *, + make_llm_client: LLMClientFactory | None = None, + env_builders: dict[str, Any] | None = None, +): """Unified training entry point. Builds layers, environment adapter, and runs the learning loop. Environment is selected via env_type and constructed by the matching - builder from ENV_BUILDERS. + builder from ``env_builders`` (defaults to the module-level + ``ENV_BUILDERS``). + + Both ``make_llm_client`` and ``env_builders`` are keyword-only so + callers (CLI dry-run, tests) can inject mocks / stubs without + mutating module-level state. Defaults preserve current behavior. """ from clawloop.core.intensity import AdaptiveIntensity from clawloop.core.loop import AgentState, learning_loop @@ -438,6 +477,9 @@ def train(config: TrainConfig): from clawloop.learning_layers.router import Router from clawloop.learning_layers.weights import Weights + factory: LLMClientFactory = make_llm_client or _make_llm_client + builders = env_builders if env_builders is not None else ENV_BUILDERS + layers = validate_config(config) # 1. Harness — reflector wired via LocalEvolver when harness layer is active @@ -450,7 +492,7 @@ def train(config: TrainConfig): from clawloop.core.reflector import Reflector from clawloop.harness_backends.local import LocalEvolver - reflector_client = _make_llm_client(config.llm_clients["reflector"]) + reflector_client = factory(config.llm_clients["reflector"]) evolver = LocalEvolver(reflector=Reflector(client=reflector_client)) harness = Harness(system_prompts=prompts, evolver=evolver) @@ -482,8 +524,8 @@ def train(config: TrainConfig): router = Router() # 3. Environment adapter — dispatched via registry - build_env = ENV_BUILDERS[config.env_type] - adapter, tasks = build_env(config, config.llm_clients) + build_env = builders[config.env_type] + adapter, tasks = build_env(config, config.llm_clients, factory) # Wire harness into adapter for proxy skill injection (openclaw) if hasattr(adapter, "set_harness"): diff --git a/tests/test_cli.py b/tests/test_cli.py index ba2d4332..31374b2e 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -154,27 +154,20 @@ def test_run_openclaw_dry_run_no_api_calls(tmp_path: Path, monkeypatch): # --------------------------------------------------------------------------- -# Dry-run role tagging (PR #60 comment 2; PR #62 comment 1; issue #63) +# Dry-run via dependency injection (issue #64) # --------------------------------------------------------------------------- -def test_dry_run_subclass_survives_pydantic_copy(monkeypatch): - """`_install_dry_run_clients` replaces each entry in `config.llm_clients` - with a `_DryRunLLMClientConfig` instance. The subclass: - * survives `model_copy()` (Pydantic preserves runtime class), - * leaves `model` untouched so downstream consumers see it verbatim, - * is detected via `isinstance` by the patched `_make_llm_client`. - - Regression for issue #63: ensures the role does not live on the public - `LLMClientConfig` schema.""" +def test_dry_run_overrides_map_roles_correctly(): + """`_build_dry_run_overrides(config)` returns a `(factory, builders)` + pair. The factory maps each `LLMClientConfig` in `config.llm_clients` + to the correct mock based on role — purely via closure, no module-level + patching required. Regression for issue #64.""" import clawloop.cli as _cli - import clawloop.train as _train from clawloop.demo_math import MockTaskClient from clawloop.llm import MockLLMClient from clawloop.train import LLMClientConfig, TrainConfig - original_make = _train._make_llm_client - cfg = TrainConfig( mode="harness_learning", env_type="math", @@ -184,39 +177,74 @@ def test_dry_run_subclass_survives_pydantic_copy(monkeypatch): }, ) - _cli._install_dry_run_clients(cfg) - try: - reflector = cfg.llm_clients["reflector"] - task = cfg.llm_clients["task"] - - # Each entry is now a _DryRunLLMClientConfig subclass instance. - assert isinstance(reflector, _cli._DryRunLLMClientConfig) - assert isinstance(task, _cli._DryRunLLMClientConfig) - # `model` is preserved verbatim — no marker pollution. - assert reflector.model == "anthropic/claude-sonnet-4" - assert task.model == "anthropic/claude-haiku-4" - # The role lives on the subclass instance. - assert reflector.dry_run_role == "reflector" - assert task.dry_run_role == "task" - - # `model_copy()` preserves the runtime class so the tag survives. - copied_reflector = reflector.model_copy() - copied_task = task.model_copy() - assert id(copied_reflector) != id(reflector) - assert isinstance(copied_reflector, _cli._DryRunLLMClientConfig) - assert copied_reflector.dry_run_role == "reflector" - assert copied_task.dry_run_role == "task" - - assert isinstance(_train._make_llm_client(copied_reflector), MockLLMClient) - assert isinstance(_train._make_llm_client(copied_task), MockTaskClient) - finally: - _train._make_llm_client = original_make + factory, _builders = _cli._build_dry_run_overrides(cfg) + assert isinstance(factory(cfg.llm_clients["reflector"]), MockLLMClient) + assert isinstance(factory(cfg.llm_clients["task"]), MockTaskClient) + + +def test_dry_run_overrides_stub_bypassing_env(): + """For an env_type whose builder bypasses `make_llm_client` + (e.g. taubench), `_build_dry_run_overrides` returns an `env_builders` + override whose builder yields a `_StubAdapter`. For an env_type that + uses `make_llm_client` (math), it returns the global `ENV_BUILDERS` + unchanged.""" + import clawloop.cli as _cli + import clawloop.train as _train + from clawloop.train import LLMClientConfig, TrainConfig + + cfg_taubench = TrainConfig( + mode="harness_learning", + env_type="taubench", + llm_clients={"reflector": LLMClientConfig(model="x")}, + env_config={"domain": "retail"}, + ) + _, builders = _cli._build_dry_run_overrides(cfg_taubench) + assert builders is not _train.ENV_BUILDERS # override built + adapter, tasks = builders["taubench"](cfg_taubench, cfg_taubench.llm_clients, None) + assert isinstance(adapter, _cli._StubAdapter) + assert tasks # non-empty + + cfg_math = TrainConfig( + mode="harness_learning", + env_type="math", + llm_clients={ + "reflector": LLMClientConfig(model="r"), + "task": LLMClientConfig(model="t"), + }, + ) + _, builders_math = _cli._build_dry_run_overrides(cfg_math) + assert builders_math is _train.ENV_BUILDERS # no override for math + + +def test_dry_run_does_not_mutate_module_state(tmp_path: Path): + """After `cmd_run --dry-run`, `_make_llm_client` and `ENV_BUILDERS` on + `clawloop.train` are unchanged. Regression for the global-mutation + footgun that the prior monkey-patching approach created.""" + import argparse as _argparse + + import clawloop.train as _train + from clawloop.cli import cmd_run + + original_make_llm = _train._make_llm_client + original_builders_dict = dict(_train.ENV_BUILDERS) + + raw = json.loads((CONFIGS_DIR / "math_harness.json").read_text()) + raw["n_iterations"] = 1 + raw["episodes_per_iter"] = 1 + cfg_path = tmp_path / "math_tiny.json" + cfg_path.write_text(json.dumps(raw)) + + args = _argparse.Namespace(config=cfg_path, dry_run=True) + cmd_run(args) + + assert _train._make_llm_client is original_make_llm + assert dict(_train.ENV_BUILDERS) == original_builders_dict def test_public_llm_client_config_schema_excludes_dry_run_vocab(): """The public `LLMClientConfig` schema must not carry any dry-run - testing vocabulary — that lives on the private `_DryRunLLMClientConfig` - subclass instead. Acceptance criterion for issue #63.""" + testing vocabulary. After issue #64 the role lives entirely in a + closure inside `_build_dry_run_overrides` — no field, no subclass.""" from clawloop.train import LLMClientConfig schema = LLMClientConfig.model_json_schema() @@ -225,7 +253,6 @@ def test_public_llm_client_config_schema_excludes_dry_run_vocab(): "dry_run_role" not in properties ), f"LLMClientConfig should not expose dry_run_role; got {sorted(properties)}" - # And model_dump of a vanilla instance must not emit it either. dumped = LLMClientConfig(model="anthropic/x").model_dump() assert ( "dry_run_role" not in dumped diff --git a/tests/test_train_config.py b/tests/test_train_config.py index 446f1ba1..ac965174 100644 --- a/tests/test_train_config.py +++ b/tests/test_train_config.py @@ -353,8 +353,8 @@ def test_explicit_key_preserved(self): class TestTrainEndToEnd: def test_harness_learning_math(self): - """Full pipeline: train() with harness_learning + math env (mocked LLMs).""" - from unittest.mock import MagicMock, patch + """Full pipeline: train() with harness_learning + math env (DI mocks).""" + from unittest.mock import MagicMock from clawloop.train import LLMClientConfig, TrainConfig, train @@ -374,15 +374,11 @@ def test_harness_learning_math(self): n_iterations=1, ) - with patch("clawloop.train._make_llm_client") as mock_make: - - def _pick_client(llm_cfg): - if "reflector" in llm_cfg.model: - return mock_reflector - return mock_task - - mock_make.side_effect = _pick_client + def _pick_client(llm_cfg): + if "reflector" in llm_cfg.model: + return mock_reflector + return mock_task - agent_state, state_id = train(cfg) - assert state_id.combined_hash - assert mock_task.complete.called + agent_state, state_id = train(cfg, make_llm_client=_pick_client) + assert state_id.combined_hash + assert mock_task.complete.called diff --git a/tests/unit/test_train.py b/tests/unit/test_train.py index 62d69996..85c785ad 100644 --- a/tests/unit/test_train.py +++ b/tests/unit/test_train.py @@ -21,7 +21,7 @@ def test_build_openspiel_tasks_repeat_per_seed(): n_iterations=1, ) builder = ENV_BUILDERS["openspiel"] - adapter, tasks = builder(cfg, cfg.llm_clients) + adapter, tasks = builder(cfg, cfg.llm_clients, None) assert len(tasks) == 8 assert tasks.count("blackjack_seed_0") == 4 assert tasks.count("blackjack_seed_1") == 4 @@ -77,7 +77,7 @@ def test_build_openspiel_mixed_games_interleaves_tasks(): n_iterations=1, ) builder = ENV_BUILDERS["openspiel"] - adapter, tasks = builder(cfg, cfg.llm_clients) + adapter, tasks = builder(cfg, cfg.llm_clients, None) # 2 blackjack seeds * 3 + 2 2048 seeds * 2 = 10 assert len(tasks) == 10 assert tasks.count("blackjack_seed_0") == 3 From 7d8e894b9c63da5f489ce19a4e40f410109f9390 Mon Sep 17 00:00:00 2001 From: dantp-ai <1534513+dantp-ai@users.noreply.github.com> Date: Fri, 12 Jun 2026 02:51:52 +0200 Subject: [PATCH 2/2] fix: replace None with lambda dummies for builder make_llm_client arg MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per gemini-code-assist review on PR #66: passing `None` for the `make_llm_client` parameter violates the declared type signature `LLMClientFactory = Callable[[LLMClientConfig], Any]`. The builders we invoke (openspiel, taubench stub) never call the factory, but the type contract still applies. Replace each `None` with `lambda _: None` — a callable that ignores its arg and returns None. Satisfies the type signature; behavior is unchanged because the callees don't invoke it. Co-Authored-By: Claude Opus 4.7 --- tests/test_cli.py | 2 +- tests/unit/test_train.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 31374b2e..e41e4949 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -200,7 +200,7 @@ def test_dry_run_overrides_stub_bypassing_env(): ) _, builders = _cli._build_dry_run_overrides(cfg_taubench) assert builders is not _train.ENV_BUILDERS # override built - adapter, tasks = builders["taubench"](cfg_taubench, cfg_taubench.llm_clients, None) + adapter, tasks = builders["taubench"](cfg_taubench, cfg_taubench.llm_clients, lambda _: None) assert isinstance(adapter, _cli._StubAdapter) assert tasks # non-empty diff --git a/tests/unit/test_train.py b/tests/unit/test_train.py index 85c785ad..91033cfe 100644 --- a/tests/unit/test_train.py +++ b/tests/unit/test_train.py @@ -21,7 +21,7 @@ def test_build_openspiel_tasks_repeat_per_seed(): n_iterations=1, ) builder = ENV_BUILDERS["openspiel"] - adapter, tasks = builder(cfg, cfg.llm_clients, None) + adapter, tasks = builder(cfg, cfg.llm_clients, lambda _: None) assert len(tasks) == 8 assert tasks.count("blackjack_seed_0") == 4 assert tasks.count("blackjack_seed_1") == 4 @@ -77,7 +77,7 @@ def test_build_openspiel_mixed_games_interleaves_tasks(): n_iterations=1, ) builder = ENV_BUILDERS["openspiel"] - adapter, tasks = builder(cfg, cfg.llm_clients, None) + adapter, tasks = builder(cfg, cfg.llm_clients, lambda _: None) # 2 blackjack seeds * 3 + 2 2048 seeds * 2 = 10 assert len(tasks) == 10 assert tasks.count("blackjack_seed_0") == 3