Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions envs/harbor_env/harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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 -----------------------------------------------------
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
)


Expand Down
45 changes: 45 additions & 0 deletions tests/envs/test_harbor_session_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=[]))
Expand Down