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..2e7fb4d24 100644 --- a/tests/envs/test_harbor_session_factory.py +++ b/tests/envs/test_harbor_session_factory.py @@ -126,6 +126,51 @@ def test_factory_rejects_an_invalid_training_policy_before_opening_a_client(): ) +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, 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"] == "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( + [{"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=[]))