From 7474670757e61243f9902da4d33397335298b42b Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 30 Sep 2026 17:45:16 +0000 Subject: [PATCH 1/2] harbor_env: forward purpose, provider and eval_sampling from HarborSessionFactory HarborSessionFactory is where the opencode_env deprecation (#1276) sends people, but it can't pass `purpose`, `provider` or `eval_sampling`, even though `HarborEnv.run_rollout` and the server's `run_rollout` tool both take them. So a TRL loop-owning run can't ask for `purpose="train"` (which the provider qualification guide recommends), and can't point at a native Anthropic endpoint. The train case is the one that bites: a vLLM started without `--return-tokens-as-token-ids` still runs every rollout, just as eval, and `fetch_proxy_trace()` hands the trainer an empty list per rollout with a warning. With `purpose="train"` the server refuses the rollout before a sandbox is created ("training requires exact engine token capture"). Fix: add the three kwargs to the factory and session and pass them through. The factory checks them at construction the same way it already checks `sampling`, mirroring `SessionRegistry.create`, so a bad value fails once instead of on every rollout. Defaults are unchanged, and the client already leaves defaults off the wire for older servers. Tests: 6 new cases in tests/envs/test_harbor_session_factory.py fail on main (TypeError on the new kwargs) and pass with this change. tests/envs/test_harbor_*.py + test_capture_*.py: 606 passed, 3 skipped. usort, ruff format and ruff check clean on the touched files. Prepared with AI assistance (Claude Code); I reviewed the change and ran the tests. Co-Authored-By: Claude Opus 5.5 --- envs/harbor_env/harness.py | 35 +++++++++++++++++++++ tests/envs/test_harbor_session_factory.py | 37 +++++++++++++++++++++++ 2 files changed, 72 insertions(+) diff --git a/envs/harbor_env/harness.py b/envs/harbor_env/harness.py index 4dc0c15df..9b5437486 100644 --- a/envs/harbor_env/harness.py +++ b/envs/harbor_env/harness.py @@ -42,6 +42,7 @@ ToolResult, VerifyResult, ) +from openenv.core.harness.capture.sessions import evaluation_sampling from openenv.core.harness.capture.upstream import training_sampling from openenv.core.harness.training import TrainingTrace from openenv.harbor.client import HarborEnv @@ -179,6 +180,9 @@ def __init__( agent_timeout_sec: float = 0.0, agent_step_limit: int = 0, sampling: dict[str, Any] | None = None, + provider: str = "openai", + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, owns_env: bool = False, ) -> None: self._env = env @@ -198,6 +202,9 @@ def __init__( self._agent_timeout_sec = agent_timeout_sec self._agent_step_limit = agent_step_limit self._sampling = training_sampling(sampling) if sampling is not None else None + self._provider = provider + self._purpose = purpose + self._eval_sampling = eval_sampling self.result: HarborRolloutResult | None = None # --- ResourceSession ----------------------------------------------------- @@ -265,6 +272,9 @@ def wait_for_completion(self, timeout_s: float | None = None) -> int: model=self._model, api_key=self._api_key, auth_header=self._auth_header, + provider=self._provider, + purpose=self._purpose, + eval_sampling=self._eval_sampling, # `is not None`, not `or`: 0 is a documented value meaning "defer to the task # file", and `or` silently replaces it with the factory default. `OpenCodeSession` # takes the same care for the same reason. @@ -371,6 +381,14 @@ class HarborSessionFactory(ResourceSessionFactory[HarborSession]): sampling (`dict`, *optional*): Explicit full-vocabulary training policy with the trainer's temperature. Validated before opening clients and forwarded on every session, regardless of harness. + provider (`str`, *optional*, defaults to `"openai"`): + Wire protocol of `llm_url`, e.g. `"anthropic"` for a native Anthropic endpoint. + purpose (`str`, *optional*, defaults to `"auto"`): + `"train"` fails each rollout up front when the engine cannot return token ids, instead + of running it as an eval rollout with nothing to train on. `"eval"` never exports a + training contract. `"auto"` takes whatever the engine's probed tier gives. + eval_sampling (`dict`, *optional*): + Sampling for eval rollouts (`temperature`, `top_p`, `top_k`). Requires `purpose="eval"`. Examples: @@ -399,6 +417,9 @@ def __init__( agent_timeout_sec: float = 600.0, agent_step_limit: int = 0, sampling: dict[str, Any] | None = None, + provider: str = "openai", + purpose: str = "auto", + eval_sampling: dict[str, Any] | None = None, num_tasks: int | None = None, indices: list[int] | None = None, max_message_size_mb: float = 4096.0, @@ -414,6 +435,17 @@ def __init__( self.agent_timeout_sec = agent_timeout_sec self.agent_step_limit = agent_step_limit self.sampling = training_sampling(sampling) if sampling is not None else None + # Checked here for the same reason as `sampling`: the server rejects these per rollout, so + # a bad value would otherwise fail every rollout of the run one at a time. + if purpose not in {"auto", "eval", "train"}: + raise ValueError("purpose must be auto, eval, or train") + if purpose == "eval" and sampling is not None: + raise ValueError("eval purpose cannot apply a training sampling override") + if evaluation_sampling(eval_sampling) and purpose != "eval": + raise ValueError("eval_sampling requires explicit eval purpose") + self.provider = provider + self.purpose = purpose + self.eval_sampling = eval_sampling self._num_tasks = num_tasks # Specific tasks, rather than the first N of the split. Which tasks a group trains on decides # whether it can learn anything at all: a task every generation solves and one none solves both @@ -566,6 +598,9 @@ def create( agent_timeout_sec=self.agent_timeout_sec, agent_step_limit=self.agent_step_limit, sampling=self.sampling, + provider=self.provider, + purpose=self.purpose, + eval_sampling=self.eval_sampling, ) diff --git a/tests/envs/test_harbor_session_factory.py b/tests/envs/test_harbor_session_factory.py index bec3ca931..39b926230 100644 --- a/tests/envs/test_harbor_session_factory.py +++ b/tests/envs/test_harbor_session_factory.py @@ -126,6 +126,43 @@ def test_factory_rejects_an_invalid_training_policy_before_opening_a_client(): ) +def test_factory_forwards_purpose_and_provider_to_each_rollout(): + """Without `purpose="train"` a vLLM started without token capture runs every rollout as eval, + and the trainer gets an empty trace per rollout instead of an error.""" + env = FakeEnv(result()) + session = factory_with( + env, provider="anthropic", purpose="train", sampling={"temperature": 0.8} + ).create([{"role": "user", "content": "first task"}]) + assert session.wait_for_completion() == 0 + call = env.calls[0] + assert call["provider"] == "anthropic" + assert call["purpose"] == "train" + assert call["eval_sampling"] is None + + +def test_factory_forwards_eval_sampling_with_eval_purpose(): + env = FakeEnv(result(rollout_type="eval", capture_level="text", turns=[])) + factory_with(env, purpose="eval", eval_sampling={"temperature": 0.0}).create( + [{"role": "user", "content": "first task"}] + ).wait_for_completion() + assert env.calls[0]["purpose"] == "eval" + assert env.calls[0]["eval_sampling"] == {"temperature": 0.0} + + +@pytest.mark.parametrize( + "kwargs, match", + [ + ({"purpose": "training"}, "purpose must be"), + ({"purpose": "eval", "sampling": {"temperature": 0.8}}, "eval purpose"), + ({"eval_sampling": {"temperature": 0.0}}, "requires explicit eval"), + ({"purpose": "eval", "eval_sampling": {"seed": 1}}, "eval_sampling"), + ], +) +def test_factory_rejects_an_invalid_purpose_before_opening_a_client(kwargs, match): + with pytest.raises(ValueError, match=match): + harness.HarborSessionFactory("http://unused.invalid", **kwargs) + + def test_an_eval_rollout_yields_nothing_trainable(): """It has a reward and a readable trace; what it has no business producing is training rows.""" env = FakeEnv(result(rollout_type="eval", capture_level="text", turns=[])) From f56790040c883efba3f377eec35da1754085dfe9 Mon Sep 17 00:00:00 2001 From: "Eugenio \"Jay\" Zuccarelli" <11176606+jayzuccarelli@users.noreply.github.com> Date: Wed, 30 Sep 2026 19:29:47 +0000 Subject: [PATCH 2/2] harbor_env: forward purpose, provider and eval_sampling from HarborSessionFactory `HarborSessionFactory` (where #1276 points opencode_env users) can't pass `purpose`, `provider` or `eval_sampling`, although `HarborEnv.run_rollout` and the server's `run_rollout` tool accept all three. So a TRL loop-owning run can't ask for `purpose="train"`, which the provider qualification guide recommends. That matters most for training: a vLLM started without `--return-tokens-as-token-ids` still runs every rollout, as eval, and `fetch_proxy_trace()` hands the trainer an empty list per rollout with only a warning. With `purpose="train"` the server refuses the rollout before creating a sandbox. The factory now takes the three kwargs and passes them through. It validates them at construction, like `sampling`, so a bad value fails once rather than on every rollout. Defaults are unchanged. Tests: new cases in `tests/envs/test_harbor_session_factory.py` fail on main (TypeError on the new kwargs) and pass with this change; the harbor and capture suites pass (606 passed, 3 skipped). ruff and usort clean on the touched files. Prepared with AI assistance (Claude Code); I reviewed the change and ran the tests. Co-Authored-By: Claude Opus 5.5 --- tests/envs/test_harbor_session_factory.py | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/tests/envs/test_harbor_session_factory.py b/tests/envs/test_harbor_session_factory.py index 39b926230..2e7fb4d24 100644 --- a/tests/envs/test_harbor_session_factory.py +++ b/tests/envs/test_harbor_session_factory.py @@ -126,20 +126,28 @@ def test_factory_rejects_an_invalid_training_policy_before_opening_a_client(): ) -def test_factory_forwards_purpose_and_provider_to_each_rollout(): +def test_factory_forwards_purpose_to_each_rollout(): """Without `purpose="train"` a vLLM started without token capture runs every rollout as eval, and the trainer gets an empty trace per rollout instead of an error.""" env = FakeEnv(result()) - session = factory_with( - env, provider="anthropic", purpose="train", sampling={"temperature": 0.8} - ).create([{"role": "user", "content": "first task"}]) + session = factory_with(env, purpose="train", sampling={"temperature": 0.8}).create( + [{"role": "user", "content": "first task"}] + ) assert session.wait_for_completion() == 0 call = env.calls[0] - assert call["provider"] == "anthropic" + assert call["provider"] == "openai" assert call["purpose"] == "train" assert call["eval_sampling"] is None +def test_factory_forwards_provider_to_each_rollout(): + env = FakeEnv(result(rollout_type="eval", capture_level="text", turns=[])) + factory_with(env, provider="anthropic", purpose="eval").create( + [{"role": "user", "content": "first task"}] + ).wait_for_completion() + assert env.calls[0]["provider"] == "anthropic" + + def test_factory_forwards_eval_sampling_with_eval_purpose(): env = FakeEnv(result(rollout_type="eval", capture_level="text", turns=[])) factory_with(env, purpose="eval", eval_sampling={"temperature": 0.0}).create(