Skip to content

Harbor integration: serve Harbor task datasets through OpenEnv as trainable environments - #1036

Merged
burtenshaw merged 99 commits into
huggingface:mainfrom
adithya-s-k:harbor-integration
Sep 16, 2026
Merged

burtenshaw merged 99 commits into
huggingface:mainfrom
adithya-s-k:harbor-integration

Conversation

@adithya-s-k

@adithya-s-k adithya-s-k commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Harbor agents need a shared OpenEnv service for evaluating hosted models and collecting exact-token training rollouts. This adds the task/service/client integration, capture proxy and default Gradio playground used by the data-agent runs.

  • Keep credentials, sampling, output budgets and live traces scoped to each session.
  • Separate eval/train purpose. Training export validates engine token IDs, aligned logprobs and authoritative loss masks; unsupported conversions and fatal capture findings fail closed.
  • Handle streaming keepalives, auxiliary-call eligibility, prompt rewrites and sandbox cleanup, with qualified OpenAI/vLLM, Anthropic, HF, ACP and NeMo paths.
  • Retain the two-column playground with bordered model/task/agent panels, clear run status, live completed calls and an advanced contract download.

RFC 012 extends RFCs 005/006 and PR #941. Qualification is versioned evidence for tested combinations; it does not promise support for arbitrary models or endpoints. The proxy buffers responses, so traces update after each call. Training-row weighting stays with the trainer. Agent execution and sandbox lifecycle have separate timeout bounds.

Validation: final CI passes on Python 3.11/3.12, including package, lint, docs and lock checks. Local validation includes 2,393 CPU tests (unrelated QED services excluded), focused capture/session-budget tests, and 334 client/discovery/Harbor regressions after the final upstream merge. Desktop/mobile browser checks found no JavaScript errors or horizontal overflow; concurrent trace ownership is covered. The qualification report records exact passing subsets of 29 adapters × four provider profiles × two tasks. Local whole-repository lint reported 28 unchanged upstream formatting findings; changed files and CI are clean.

Related: huggingface/trl#6947 consumes the contract; adithya-s-k/HuggingEnvs#7 contains reproduction and deployed-Space GPU smoke receipts. Active training jobs and original artifacts were preserved.

An OpenAI-spec proxy that sits between a coding agent and an inference
endpoint and records the exact token ids and per-token logprobs of every
model call, so a rollout is trainable.

Nothing is tokenised locally: the engine returns prompt_token_ids, so turn
k+1's prompt is the canonical tokenisation of everything before it and turns
link by exact token prefix. Re-rendering a prompt offline drifts from what
the model saw, and a drifted prompt silently fragments one conversation into
several.

Four wire dialects (chat-completions, OpenAI Responses, Anthropic Messages,
Google generateContent), adapted from the Polar gateway (Apache-2.0);
provenance in dialects/README.md. Vendored because the package named polar
on PyPI is unrelated. Two ideas are borrowed from verifiers: aux routes, so
a count_tokens call is answered without becoming a model turn, and
per-dialect streaming detection, since Google signals streaming in the URL.

Includes engine certification, which refuses an endpoint that cannot return
token ids, and port forwarding for sandboxes that cannot reach localhost.
Serves Harbor's task datasets over the Task API and runs a rollout through
one long-running MCP tool, with the agent and the sandbox chosen per call
rather than baked into the deployment.

A failed rollout returns a result, never an exception. That is the reason
this layer exists: in the in-process predecessor a rollout exception reached
the trainer and hung every rank at the NCCL barrier, which is why
trl.experimental.harbor wraps nearly every environment call individually.
Behind an HTTP boundary that failure class cannot occur.

Rewards are forwarded, never recomputed. Harbor's dict travels verbatim and
the scalar is chosen by an explicit rule, refusing rather than guessing when
several keys exist. reward=None is not zero: it means the verifier never ran,
and conflating them makes a dead sandbox look like a wrong answer.

Sandbox availability is asked for rather than assumed. A backend counts as
usable only if its class imports, its SDK is present, and Harbor's own
preflight passes; checking credentials alone reports a backend available and
then fails at rollout time.

Hosted deployments mount the capture proxy on the env server's own app, since
a Space has one port and one public URL and nothing needs forwarding there.
info reports what this machine can actually run. rollout runs one end to end
with no server involved, which halves the search space when something breaks:
if rollout works and serve does not, the fault is in the serving layer.
serve is the env server; push deploys the same thing to a Space.

--llm-url is required with no default and no environment fallback, because an
unset endpoint produces rollouts that look completely normal and carry no
token ids.

push attaches the task suites as a bucket volume mounted at /data instead of
downloading them: a Harbor suite is thousands of small files and Space disk is
ephemeral, so a download is re-paid on every restart. Copies are server side,
by xet hash. The mount is verified before the server is pointed at it, and it
falls back to downloading rather than reading paths that may not exist.

The harbor extra installs every sandbox backend. Not harbor[cloud], which is
unsatisfiable: it pulls langsmith[sandbox] and tensorlake, which demand
incompatible websockets ranges.
Manifest, Dockerfile and ASGI entry point only; the logic lives in
openenv.harbor so the capture layer can be shared rather than duplicated per
agent environment.

The Dockerfile pins UV_PYTHON_INSTALL_DIR and copies it across the stage
boundary. Harbor needs Python >= 3.12 while openenv-base ships 3.11, so uv
downloads its own interpreter and the venv's bin/python is a symlink into it;
copying only .venv leaves a dangling link and the container dies with
'not found'. A build-time assertion now catches that at build rather than at
startup.

The entry point resolves and validates the served model the way harbor serve
does. Without it the proxy has no served model id and forwards whatever name
the harness used straight to the engine.
Each of these pins a failure that was silent in production and cheap to
reintroduce. No credentials or network needed.

Port ownership: a capture server used to report healthy on a port another
process owned, because the liveness probe connected to the incumbent while its
own bind error died unobserved on a background thread. Sessions were then
minted in one registry and rejected by another, producing a 401 and a rollout
with zero model calls.

Request normalisation: kimi-cli sends tools: [] once its loop has no tools
left, and vLLM rejects an empty array outright, truncating the trajectory
while leaving a well-formed graph behind.

Hosted serving: a Space must mount the capture proxy rather than forward it.
One test monkeypatches make_forwarder to raise, so a hosted deployment that
ever tries to forward fails the suite.
Copilot AI lite review requested due to automatic review settings August 2, 2026 19:24
@bot-ci-comment

bot-ci-comment Bot commented Aug 2, 2026

Copy link
Copy Markdown

The docs for this PR live here. All of your documentation changes will be reflected on that endpoint. The docs are available until 30 days after the last update.

check-env-docs generates a docs stub per environment README and fails when one
is missing, which it was.

The README line about the capture proxy being the only thing forwarded
publicly predated the hosted path and was wrong for a Space, where there is one
port and one public URL and the proxy is mounted rather than forwarded. Fixed
in the README so the generated stub follows.

_toctree.yml is maintained by hand, so the generated page needs an entry there
or it exists without being reachable from the sidebar.
Comment thread src/openenv/core/harness/capture/forwarding.py Outdated
Comment thread src/openenv/harbor/models.py Outdated
Comment thread src/openenv/core/harness/capture/server.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

There are correctness issues in the capture/training contract plumbing (async asyncio.run usage, incomplete per-turn prompt IDs, and dropping additional agent roots) that would cause silent data loss or runtime failures in valid usage paths.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Pull request overview

Adds a Harbor-backed environment integration that can serve Harbor task datasets through OpenEnv and produce trainable rollouts by capturing engine-native token IDs + per-token logprobs (via a multi-dialect capture proxy), plus Harbor verifier rewards.

Changes:

  • Introduces openenv.core.harness.capture: a capture proxy + rollout-graph + validation/export utilities (incl. dialect adapters and port-forwarding).
  • Adds openenv.harbor package: dataset discovery, capabilities/preflight reporting, rollout runner, serving layer (including hosted “single-port” mounting behavior), typed client, and ATIF reconciliation.
  • Adds deployable envs/harbor_env packaging (Space/FastAPI entrypoint + Dockerfile) and wires a new openenv harbor CLI group + openenv[harbor] extra.
File summaries
File Description
tests/envs/test_harbor_hosted_serving.py Pins single-port hosted/Space behavior (mount capture; never forward).
tests/envs/test_harbor_capture_server.py Pins capture server port-ownership + instance-identity invariants.
tests/envs/test_harbor_capture_normalise.py Asserts request normalization that avoids vLLM 400s that silently truncate rollouts.
src/openenv/harbor/tasks.py Implements dataset spec resolution (HF repo/local/registry) with caching + prefetch.
src/openenv/harbor/startup.py Startup gating/preflight (LLM capture capability, sandboxes, datasets) with report rendering.
src/openenv/harbor/serving.py Serving layer that chooses between forwarding (local) vs mount-at-/capture (hosted).
src/openenv/harbor/runner.py CLI rollout runner: boot capture + forwarder, run tasks, print batch reports.
src/openenv/harbor/rollout.py Core rollout execution and “never raise” result shaping, plus ATIF reconciliation integration.
src/openenv/harbor/models.py Wire models (HarborRolloutResult, HarborTurn, etc.) and document-to-wire transformations.
src/openenv/harbor/environment.py MCPEnvironment wrapper exposing run_rollout + discovery tools and Task API duck-typing.
src/openenv/harbor/client.py Typed client for Task API + MCP tool execution with long timeouts.
src/openenv/harbor/capabilities.py Capability discovery for harnesses/sandboxes/datasets, using Harbor preflight.
src/openenv/harbor/atif.py ATIF ingest + reconciliation, and optional merge of captured tokens/logprobs into ATIF.
src/openenv/harbor/init.py Package overview and dependency/layering notes.
src/openenv/core/harness/capture/validate.py Validation logic for per-turn/per-sequence/per-rollout invariants.
src/openenv/core/harness/capture/validate_llm.py Live probe to certify LLM returns token IDs + logprobs required for capture.
src/openenv/core/harness/capture/upstream.py vLLM-only upstream client + request/response normalization.
src/openenv/core/harness/capture/sse.py Synthetic SSE replay: capture non-streaming, respond streaming for harness compatibility.
src/openenv/core/harness/capture/sessions.py Session multiplexing/routing (API key == session id) and session summaries.
src/openenv/core/harness/capture/graph.py Prefix-linked rollout graph + training-sequence flattening.
src/openenv/core/harness/capture/forwarding.py Port forwarder strategies (direct/gradio/cloudflare) with preflight + reliability constraints.
src/openenv/core/harness/capture/export.py Export graph to validated JSON training document + role assignment.
src/openenv/core/harness/capture/dialects/reasoning.py Reasoning/thinking block round-trip helpers used by dialect transformers.
src/openenv/core/harness/capture/dialects/README.md Provenance + transformer scope/notes for vendored dialect code.
src/openenv/core/harness/capture/dialects/openai_chat.py Chat-completions transformer shim.
src/openenv/core/harness/capture/dialects/images.py Multimodal/image block conversions across dialects.
src/openenv/core/harness/capture/dialects/base.py Base transformer + request normalization helpers (developer role merge, per-model fixes).
src/openenv/core/harness/capture/dialects/init.py Transformer dispatch manager by detected API dialect.
src/openenv/core/harness/capture/detection.py Dialect detection logic (path/header/body heuristics).
src/openenv/core/harness/capture/contract.py Adapter layer exporting capture to downstream consumer “contracts” (TRL, per-turn records).
src/openenv/core/harness/capture/init.py Public exports for the capture subsystem.
src/openenv/cli/main.py Adds openenv harbor Typer subcommand group.
pyproject.toml Adds openenv[harbor] optional extra with Python>=3.12 marker.
envs/harbor_env/server/Dockerfile Space/deployment image build (uv + Python 3.12 carry-through) and runtime entrypoint.
envs/harbor_env/server/app.py ASGI app that validates LLM, starts/mounts capture, and builds the env server app.
envs/harbor_env/server/init.py Package marker for deployed server module.
envs/harbor_env/README.md Environment-level usage + config docs for Space deployment.
envs/harbor_env/pyproject.toml Environment packaging deps (openenv + harbor extras + server deps).
envs/harbor_env/openenv.yaml OpenEnv deployment manifest for the harbor_env Space runtime.
envs/harbor_env/models.py Re-export wire types for harbor_env.* parity with other env packages.
envs/harbor_env/client.py Re-export typed client for environment package ergonomics.
envs/harbor_env/init.py Env package overview + re-exports.
.gitignore Ignores Gradio UI build artifacts.
Review details
  • Files reviewed: 51/53 changed files
  • Comments generated: 4
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread src/openenv/harbor/tasks.py
Comment thread src/openenv/harbor/models.py Outdated
Comment thread src/openenv/core/harness/capture/contract.py Outdated
Comment thread src/openenv/harbor/capabilities.py
Copilot AI review requested due to automatic review settings August 2, 2026 19:29

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The current per-turn export populates prompt_token_ids only for the first turn (breaking the stated training contract), and there are a couple of concrete operational/error-message issues that should be corrected before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (4)

src/openenv/harbor/capabilities.py:168

  • The missing-SDK hint tells users to install harbor[cloud], but this PR explicitly documents that harbor[cloud] is unsatisfiable (see envs/harbor_env/pyproject.toml and root extra rationale). This message will send operators down a dead-end and hide the real remediation.
    src/openenv/harbor/models.py:242
  • turns_from_document only includes prompt_token_ids for the very first emitted turn (if index == 0 else []). This contradicts the stated training contract (“per turn (prompt_token_ids, completion_token_ids, per_token_logps)”) and causes every later turn in contract.json to have an empty prompt, making exact prompt-token fidelity impossible for multi-turn rollouts.
    src/openenv/harbor/tasks.py:165
  • This download path is meant to avoid HF's symlink-based snapshot layout (so Harbor's tar uploads don't preserve dangling symlinks), but snapshot_download can still create symlinks depending on huggingface_hub settings/version. Setting local_dir_use_symlinks=False makes the “real files” guarantee explicit and future-proof.
    envs/harbor_env/server/app.py:77
  • _service.start() can create background resources (capture server thread and/or external forwarder subprocess) when this module is run outside Spaces. Because startup happens at import time and there is no shutdown hook, those resources may leak until process exit (and named forwards can persist even longer). Register a shutdown handler so the service is always torn down cleanly.
# Resolve capture before the app is built. A Space gives no separate boot hook, the UI needs the
# proxy's public URL to exist by the time anyone presses Run, and `build_app` has to see the service
# in order to mount it.
if _LLM_URL:
    _service = HarborService(
  • Files reviewed: 51/53 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Copilot AI review requested due to automatic review settings August 2, 2026 19:35
Takes harbor coverage from 19 tests to 112, no credentials or network needed.
The areas chosen are the ones that fail silently rather than loudly: a bug in
any of them produces plausible training data instead of an error.

  graph        prefix linking, roots, forks, discarded branches, loss masking
  rewards      the explicit selection rule, including 0.0 vs None
  seams        model-name normalisation, session threading, dialect coverage
  discovery    ordering stability, the symlink regression, spec classification
  validation   ingest checks and sandbox SDK detection
  rendering    result models, verdict states, contract.json

Two real bugs surfaced while writing them.

Google streaming requests were misclassified. `detect` tested
`"generateContent" in path`, but the streaming variant capitalises the G, so
every `:streamGenerateContent` call fell through to chat-completions and would
have been parsed by the wrong transformer. `wants_stream` already lowercased
the path; `detect` did not. gemini-cli passed the sweep because it used the
non-streaming route.

Anthropic tool calls were absent from results. `models._tool_calls` read only
the chat-completions `tool_calls` key, so claude-code's `tool_use` content
blocks never reached `HarborTurn`, leaving `contract.json` and the rendered
conversation showing an agent that produced text and took no actions.

Two expectations of mine were wrong rather than the code, and are now pinned
as behaviour: an empty served model raises instead of returning an empty
string, and a turn that sampled nothing warns rather than invalidating the
rollout.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The current implementation has a few concrete contract/API mismatches (notably capture contract node selection and HarborEnv export/docs consistency) plus a misleading install hint that should be corrected before merge.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (5)

src/openenv/core/harness/capture/contract.py:43

  • _agent_nodes() only keeps nodes from the first role == "agent" sequence/root. This contradicts export._assign_roles()’s documented behavior that multiple agent roots are normal (e.g. harnesses that rewrite prompts mid-run) and will silently drop valid agent turns from to_turn_records() / to_trace_entries() output.
def _agent_nodes(graph: RolloutGraph, document: dict[str, Any]) -> list[TurnNode]:
    """Nodes on the agent's conversation, in arrival order, excluding discarded retries."""
    agent_rows = [r for r in document["sequences"] if r["role"] == "agent"]
    if not agent_rows:
        return []
    root = agent_rows[0]["root_id"]
    keep = set(agent_rows[0]["node_ids"])
    return [
        n
        for n in graph.nodes()
        if graph.root_of(n.node_id) == root and n.node_id in keep
    ]

src/openenv/harbor/capabilities.py:168

  • The missing-SDK hint recommends pip install 'harbor[cloud]', but the repo’s own dependency comments state harbor[cloud] is unsatisfiable (see root pyproject.toml harbor extra). This message will send users toward an install path that can’t work.
    src/openenv/harbor/models.py:257
  • turns_from_document() only populates prompt_token_ids for index == 0 and leaves it empty for later turns. That conflicts with the stated training contract (“per turn -> (prompt_token_ids, completion_token_ids, per_token_logps)”) and the HarborTurn docstring implying prompt_token_ids is defined for each turn.
    envs/harbor_env/init.py:9
  • The package docs/examples use from harbor_env import HarborEnv, but harbor_env/__init__.py doesn’t export HarborEnv (it only exports models). Either the docs are wrong or this module should re-export the client like other env packages (e.g. opencode_env).
from openenv.harbor.models import HarborRolloutResult, HarborTaskRef, HarborTurn

__all__ = ["HarborRolloutResult", "HarborTaskRef", "HarborTurn"]

src/openenv/harbor/environment.py:28

  • SUPPORTS_CONCURRENT_SESSIONS = True makes this environment explicitly support multiplexed concurrent trajectories on one server instance. This appears to conflict with the documented design principle “One env = one trajectory” (PRINCIPLES.md), so it would be good to confirm this is an intentional exception for harbor_env (and that downstream trainers/collectors won’t assume 1:1 env↔trajectory).
  • Files reviewed: 56/58 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

`_PROC_ENV_LOCK` guards `os.environ` while an agent is constructed, because
Harbor's wrappers read credentials there rather than from the config. It was an
`asyncio.Lock`, which binds to the first event loop that uses it and then raises
"is bound to a different event loop" for every other one.

Rollouts arrive on several loops. The env server answers each request on its
own, and any caller using `asyncio.run` per rollout creates another. So the
first concurrent rollout succeeded and the rest failed instantly, with zero
model calls and no useful error.

It passed every test and every sequential run, and only appeared under real
concurrency: 96 of 98 rollouts failed within seconds of the first parallel
sweep.

`threading.Lock` is the right primitive: the resource is global to the process,
not to a loop. It is a blocking acquire inside an async function, which is
acceptable only because construction does no I/O worth speaking of, the sandbox
is booted later by `trial.run()` outside the lock.

The regression test drives the lock from eight event loops at once, which is
the shape that failed.
Copilot AI review requested due to automatic review settings August 2, 2026 19:46
Comment thread src/openenv/core/harness/capture/contract.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

The current rollout contract output drops required per-turn prompt token ids and also truncates multi-root agent sequences, which can silently break training data correctness.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (5)

src/openenv/harbor/capabilities.py:168

  • The missing-SDK hint recommends installing harbor[cloud], but this PR explicitly documents harbor[cloud] as unsatisfiable due to dependency conflicts. Point users to openenv[harbor] (or backend-specific extras) to avoid sending them to an installation dead end.
    src/openenv/core/harness/capture/contract.py:38
  • _agent_nodes only keeps the first agent sequence/root. This drops additional agent roots (e.g. harnesses that rewrite system prompts mid-run), contradicting the capture layer’s own stance that multiple agent roots can be legitimate agent work and should remain trainable.
    agent_rows = [r for r in document["sequences"] if r["role"] == "agent"]
    if not agent_rows:
        return []
    root = agent_rows[0]["root_id"]
    keep = set(agent_rows[0]["node_ids"])

src/openenv/harbor/models.py:252

  • turns_from_document only includes prompt_token_ids for the first turn; later turns get []. Since _write_contract() serializes prompt_token_ids per turn, this produces contract files with missing prompt token ids for multi-turn rollouts, violating the stated training tuple contract.
    envs/harbor_env/README.md:28
  • This doc claims the server refuses to start when the LLM cannot return token ids, but the Space ASGI entry point (envs/harbor_env/server/app.py) intentionally boots even when LLM validation fails (to surface the error in the UI/capabilities). The docs should reflect this hosted vs CLI behavior difference.
| `MODAL_TOKEN_ID`, `MODAL_TOKEN_SECRET` | offer the `modal` sandbox |

docs/source/environments/harbor.md:28

  • This environment doc says the server refuses to start if the LLM lacks token-id capture, but the Space entry point is designed to boot and report llm.ok=false so the UI can show the fault. Align the docs with the hosted behavior (or change the Space entry point to hard-fail).
Without them it answers every request normally and returns no token ids, so captured rollouts are
empty and nothing reports an error. The server refuses to start rather than let that happen.
  • Files reviewed: 56/58 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

`ok` is what a trainer filters on, so it has to mean "this row is usable". It
did not. Only trace reconciliation could clear it, while the capture document's
own validation findings were recorded in `findings` and otherwise ignored.

A 98-rollout parallel sweep surfaced the consequence: four rollouts came back
`ok=True` with zero model calls and zero trainable tokens, because
reconciliation agreed with the capture when both sides were empty. One of them
carried reward=1.0, which is the worst available shape, a row with nothing in it
and a positive reward attached.

Any FATAL from document validation now clears `ok` and becomes the error, so
"the intercept saw no model calls" is reported as a failed rollout rather than a
successful empty one.
Copilot AI review requested due to automatic review settings August 2, 2026 20:08

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Not ready to approve

Several concrete correctness/operability issues were found in the changed code paths (per-turn prompt ids missing in turn rows, unsafe asyncio.run usage, brittle top_logprobs handling, misleading install guidance, and a capture-server lifecycle leak on forwarder failures).

Once you've addressed the issues Copilot identified, you can request another Copilot review.

This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.

Review details

Suppressed comments (3)

src/openenv/harbor/capabilities.py:168

  • The missing-SDK hint currently recommends pip install 'harbor[cloud]', but this PR’s own pyproject.toml notes that harbor[cloud] is unsatisfiable due to conflicting websockets constraints. This message will send users toward an install that cannot succeed.
    src/openenv/harbor/serving.py:104
  • If make_forwarder(...) or forwarder.start(...) fails, the capture server has already been started and will be left running. That leaks a listener/port and can make subsequent starts fail with “already in use”.
    envs/harbor_env/init.py:9
  • Docs/examples import HarborEnv via from harbor_env import HarborEnv, but harbor_env/__init__.py doesn’t export it (only models). This makes the quickstart import fail for users.
from openenv.harbor.models import HarborRolloutResult, HarborTaskRef, HarborTurn

__all__ = ["HarborRolloutResult", "HarborTaskRef", "HarborTurn"]
  • Files reviewed: 56/58 changed files
  • Comments generated: 3
  • Review effort level: Lite

We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.

Comment thread src/openenv/core/harness/capture/upstream.py Outdated
Comment thread src/openenv/harbor/environment.py Outdated
Comment thread src/openenv/harbor/models.py
Copilot AI review requested due to automatic review settings August 3, 2026 07:15
… `reward`

Two docstring corrections, both found by running the contract against a live vLLM
rather than a stub.

contract.py's module docstring still instructed consumers to re-render the prompt
with apply_chat_template, because `to_trace_entries` "has no field for the prompt's
token ids". It has had that field since the previous commit. Leaving the old advice
in place is how the original bug propagated -- a consumer reads the docstring, not
the TypedDict -- so the text now records what the re-render actually cost (0/6 turns
exact on Qwen3.5, 0/28 on -4B where the enable_thinking default is inverted) and
states plainly that nothing re-renders any more.

TraceEntry declares `reward` and nothing in capture emits it, which is correct but
was nowhere written down: a proxy sees model calls, not task outcomes, and
inventing a number would be worse than omitting one. An environment fills it in
from its own verify(). The TypedDict is total=False precisely so a producer can
omit what it cannot know, so consumers must read it with .get() -- indexing raises
on every entry this module produces, which is exactly how this was noticed.
… something

Found by running a real opencode rollout in a real sandbox, which the stub tests
could not catch.

The budget reply went back as a plain JSON body regardless of what the caller
asked for. opencode streams. It got a non-SSE response on an SSE request, read it
as a failed generation, and retried -- so the proxy answered the stop again, and
again. One rollout logged "hit its model-call budget (10)" 281 times for a single
session while it burned its entire timeout. The log said the cap was working the
whole time.

Two fixes. The check moves after `client_wants_stream` so the reply can go back
through `sse.replay` in the dialect the caller used, exactly as the normal path
does. And the message is no longer empty: an empty assistant message also reads as
a failed generation and gets retried, so the content now states the budget is
spent. It is deliberately about the BUDGET and not about the task, because it
lands in the harness's own transcript and capture never records it -- it must not
look like something the model chose to say about the work.

Two tests, and both were confirmed to fail against the old behaviour before being
kept: one asserts a streaming caller gets `text/event-stream` with a terminal
`finish_reason` in the body, the other that the message is non-empty. Verified
live afterwards: the same rollout now logs exactly ONE budget stop, the agent
terminates, and 8 trainable turns come back with 1573 completion tokens whose
prefixes chain exactly.
The repo's lint gate runs `usort format` and then `ruff format` and fails on any resulting diff, so
running ruff alone locally is not enough to predict CI -- that misdiagnosis has cost a cycle before.
Cosmetic only: one blank line and some test-file wrapping.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/openenv/core/harness/capture/server.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/openenv/harbor/rollout.py
Comment thread src/openenv/harbor/rollout.py Outdated
… 40 concurrent

`run_rollout` was registered as a SYNC `@mcp.tool`. FastMCP dispatches a sync tool body through
`anyio.to_thread.run_sync` with no limiter (`fastmcp/utilities/async_utils.py:26-34`), which falls
back to anyio's default `CapacityLimiter(40)` (`anyio/_backends/_asyncio.py:3097`) — and the body
held that worker thread for the whole rollout. So the server admitted at most ~40 rollouts per
uvicorn process no matter what `MAX_CONCURRENT_ENVS` said; we had been running sweeps at 400.

It was invisible because `rollout.py:300` starts the clock INSIDE the tool body, after admission,
so queue time never entered `wall_s`. The throughput table we were reading off those runs
(48 -> 14.2, 96 -> 28.4, 150 -> 44.4 rollouts/min) is the identity `concurrency / mean_duration`
evaluated, not observed.

Measured, through the real dispatch chain (step_async -> _async_handle_call_tool ->
_async_call_tool -> mcp_session -> client.call_tool -> FunctionTool.run), 120 requests at a 1s hold:

    sync    PEAK=40   elapsed=3.15s   (exactly ceil(120/40) waves)
    async   PEAK=120  elapsed=1.18s

Reproductions in experiments/openenv_x_harbor_learnings/tools/ — hermetic, no sandboxes or GPU.

This also closes a sandbox leak. `Trial._finalize` -> `_stop_agent_environment` asyncio.SHIELDS
`agent_environment.stop(delete=...)`, and `run_async_safely` ran each rollout under its own
`asyncio.run`, which closes the loop the moment the coroutine returns and cancels that shielded
teardown. Any cancelled or timed-out rollout leaked a paid E2B sandbox — which is why this has to
land before any rollout deadline, not after.

365 harbor+capture tests pass. The post-run capture block stays on the loop deliberately:
export_session measures 1.6-17.2 ms even for a 50-turn / 2.4M-token rollout, so moving it to a
thread executor is not justified; a loop-lag probe in the eval sweep will catch it if that is wrong.
…iscard the rest

`result.rewards` was built by a dict comprehension inside a single try, so the first key that could
not be coerced to a finite float threw and the handler discarded EVERY key and failed the whole
rollout. A suite emitting `tool_efficiency: null` alongside a perfectly good `correctness` therefore
lost the correctness too.

Measured on Qwen3.5-4B via opencode against HuggingEnvs/data-agent-harbor-test: of 250 tasks, the 86
whose test.sh invokes `grader.py --json` failed with "2 validation errors for VerifierResult"
whenever the agent actually produced an answer. Only the empty-submission branch of that grader
hardcodes floats, so the suite graded FAILURES correctly and crashed on every success. Graded rate
went from 1% to 97% once the reward key was also selectable per task.

Now each key is coerced on its own: usable keys are kept, unusable ones are dropped and NAMED in
`findings`, and the rollout only fails if the key actually being trained on is the unusable one --
which `_pick_reward` decides immediately below. An unmeasured key is an EXCLUSION, never a zero, so
it is dropped rather than coerced to 0.0.

Verified: {'correctness': 1.0, 'tool_efficiency': None} -> rewards {'correctness': 1.0}, picked
(1.0, 'correctness'), finding records the drop; forcing reward_key='tool_efficiency' still raises.
362 harbor/capture tests pass; usort+ruff clean; env docs in sync.

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread src/openenv/harbor/rollout.py Outdated

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale Bugbot comment from a previous run.

Comment thread envs/harbor_env/harness.py

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 39b36a5. Configure here.

Comment thread envs/harbor_env/harness.py
@burtenshaw
burtenshaw merged commit 34825a7 into huggingface:main Sep 16, 2026
11 checks passed
scosman added a commit to scosman/OpenEnv that referenced this pull request Sep 16, 2026
The `/ws` and `/mcp` endpoints close their socket in a `finally` block, which
normally runs after the client has already hung up: uvicorn raises
`ClientDisconnected` there and Starlette re-raises it as `WebSocketDisconnect`,
so the `except RuntimeError` guard those blocks originally carried never
matched and every clean teardown escaped to the ASGI layer as a logged
traceback.

Both guards already catch `WebSocketDisconnect` as of huggingface#1036. This pins that
behaviour: each endpoint is driven against an ASGI peer whose closing
handshake fails with `OSError` — what uvicorn does once the peer is gone — and
the handler must return normally. Narrowing either guard back to
`except RuntimeError` fails both cases.

Fixes huggingface#1159

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcQ9a8DBfzEXxXCeiKG72z
@cursor cursor Bot mentioned this pull request Sep 17, 2026
22 tasks

@cursor cursor Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Post-merge release review (release manager). This is now on main at 34825a77 and is therefore in the 0.5.0 candidate (#1190). One finding is holding publication; I am not opening a fix PR, so this needs an owner.

Release-gating: run_batch() defaults to expose="gradio", which publishes the capture proxy through a public tunnel, but it builds CaptureServer(...) with no admin_key. With the key unset, _admin_ok() returns True unconditionally (src/openenv/core/harness/capture/server.py:698), so GET /sessions, GET /sessions/{id}/rollout, GET /sessions/{id}/trace_entries, DELETE /sessions/{id}, and POST /sessions are all reachable without credentials on that public URL. That is precisely the enumeration, token-level trace disclosure, session deletion, and key-minting relay that _admin_ok's own docstring says the key exists to prevent. HarborService gets this right (src/openenv/harbor/serving.py:83 mints or reads OPENENV_CAPTURE_ADMIN_KEY); run_batch and the standalone python -m openenv.core.harness.capture.server CLI, which has no --admin-key flag, do not.

Both openenv.harbor and openenv.core.harness.capture are picked up by [tool.setuptools.packages.find], so this ships to PyPI users, which is why it blocks the release rather than just the Hub deployment.

Not release-gating, but a real bug: _ContextEnviron.__delitem__ deletes only from the base mapping, so pop("OVERLAY_ONLY_KEY", default) raises KeyError despite the default, and clear() empties real process-environment keys while leaving overlay keys visible. Verified by running it against this tree.

For the release I need one of: a fix that threads an admin key (or a loopback-only bind) through the public run_batch and CLI paths, a revert of this PR from the candidate, or an explicit accepted-risk decision recorded on #1190. The RFC 011 path-contract concern from earlier reviews is genuinely resolved by #1172 and is no longer a blocker.

Open in Web View Automation 

Sent by Cursor Automation: Release

trials_dir = trials_dir or Path("/tmp/openenv-harbor-trials")
trials_dir.mkdir(parents=True, exist_ok=True)

capture = CaptureServer(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

admin_key is not passed here, and expose defaults to "gradio" (line 36), so a few lines below forwarder.start(port) puts this proxy on a public URL. Because app.state.admin_key is then None, _admin_ok() short-circuits to True and every session-management route is open to anyone with the tunnel URL.

The contrast with HarborService.start (src/openenv/harbor/serving.py:83), which mints or reads OPENENV_CAPTURE_ADMIN_KEY, is what makes this look like an oversight rather than an intentional trade-off: the docstring on _admin_ok already states the key must be set "whenever the proxy is reachable from outside", and this path makes it reachable from outside by default.

def __setitem__(self, key: str, value: str) -> None:
self._base[key] = value

def __delitem__(self, key: str) -> None:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting straight from self._base ignores overlay-only keys, and since this class inherits MutableMapping, both pop() and clear() route through here. Two consequences, both reproduced against this tree:

  • pop("OVERLAY_ONLY_KEY", "default") raises KeyError even though a default was supplied, because the read resolves through the overlay but the delete hits a missing base key.
  • clear() removes the real base keys and leaves the overlay keys still visible, so the mapping is neither empty nor intact and the process environment has been partially emptied.

Deleting from the overlay first and only falling through to the base when the key is not an overlay key would fix both.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants