Conversation
Moves the trainer-side rollout API out of the package __init__ and into `openenv.core.harness.rollout`, leaving __init__ as a re-export shim. No behavior change: every name previously importable from `openenv.core.harness` still is, and is the same object. The module was ~730 lines living directly in __init__ with a docstring noting it sat outside the stable surface "while RFC 005 is still under review". Splitting it now makes room for the RFC 005 turn-based agentic harness layer to land in sibling modules instead of growing the __init__ further. Also re-exports the private `_resolve_env_reward`, which tests/scripts/test_browsergym_harness_eval_examples.py imports from the package root, and points `collect.py` at `.rollout` directly rather than importing from its own package. Consumers left untouched and verified: `openenv collect`, pi_env, opencode_env, browsergym_env, reasoning_gym_env, openspiel_env. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the type layer for wrapping an external agentic harness (Claude Code, OpenClaw, Codex) as an OpenEnv environment. No runtime behavior yet — this PR is types plus their unit tests. - `config.py`: `HarnessConfig` / `HarnessTransport`. `session_timeout_s` is documented as bounding ONE conversational turn, per the RFC's temporal-semantics section (the field comment in the RFC is ambiguous; flagging for reviewer sign-off). - `events.py`: `HarnessEventType` / `HarnessEvent` / `HarnessResponse`, plus `events_to_metadata()`, the sanctioned JSON-safe path for putting events into `Observation.metadata` so they survive wire serialization. - `adapter.py`: `AgenticHarnessAdapter` ABC and its error hierarchy. - `tools.py`: `resolve_tool_conflicts()` for the RFC's tool-name collision rules (`env_` prefixing, error on ambiguity). Two deliberate deviations from the RFC text, both because the RFC is stale against the code: 1. The RFC's `ToolDefinition` does not exist; the type is `Tool` (`env_server/mcp_types.py`), reused here rather than duplicated. Same for `RESERVED_TOOL_NAMES`, which `resolve_tool_conflicts` re-checks as defense in depth. 2. `send_message()` is concrete rather than abstract. Streaming is the single abstract turn primitive and `send_message()` drains it, which removes duplication from every concrete adapter and makes the terminal TURN_COMPLETE event an enforced contract instead of a convention. The ABC is named `AgenticHarnessAdapter` to avoid colliding with the rollout layer's existing `HarnessAdapter`. Worth discussing whether to rename the rollout classes instead and reclaim the RFC's plain names -- see the PR description. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…idge Makes the RFC 005 types runnable: an environment that owns a harness subprocess, hands it the environment's MCP tools, and turns each step() into one conversational turn. - `environment.py`: `HarnessAction` + `HarnessEnvironment(MCPEnvironment)`. reset() stops any live harness, enumerates and conflict-resolves the env tools, starts the bridge, injects, then starts the harness -- injection strictly before start, per the RFC. step() runs one turn; MCP actions keep their normal routing. Rubrics run after the turn completes, outside the harness's control loop, preserving RFC 004's reward boundary. - `process.py`: `HarnessProcess`, a loop-agnostic Popen + reader-thread helper (readiness gating, stderr-tail diagnostics, idempotent stop with SIGTERM -> SIGKILL escalation over the process group). - `bridge.py`: `HarnessMCPBridge`, serving the env's FastMCP tool surface over loopback HTTP for the harness to consume. Three decisions worth reviewer attention: 1. `HarnessEnvironment` subclasses `MCPEnvironment` and substitutes an empty internal FastMCP when `mcp=None`. `MCPEnvironment` requires `mcp_server` positionally, so the RFC's optional-mcp constructor cannot be written literally; this keeps reserved-name validation, tool enumeration and mcp_session() integration for free. 2. Popen + threads rather than asyncio subprocess transports, because the same instance must work across event loops: the sync facade spins a fresh loop per call (run_async_safely) while the server keeps one long-lived loop. Asyncio subprocess transports are bound to their creating loop. 3. The bridge is a separate loopback server rather than a reuse of the env server's /mcp endpoint. Reusing /mcp would put the orchestration routes (/reset, /step, /state) on the same origin the harness can reach, violating the RFC's security boundary; it would also hand the harness a *different* env instance, since WS /mcp creates its own session. Keeping it separate makes the boundary structural rather than filter-based. Turn timeouts and harness crashes become terminal observations (done=True, metadata.error_type) rather than exceptions, so a training loop scores the episode and moves on instead of unwinding. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four findings from the automated review, all confirmed against the code before fixing. 1. Renamed tools were unreachable (High, reported on huggingface#1100). Conflict resolution renames a colliding env tool before injection (read_file -> env_read_file), but the bridge served the source FastMCP unchanged, so the harness was handed a name that did not resolve. Adds `build_bridge_server()`, which serves a renamed view built with FastMCP's own `Tool.from_tool(tool, name=...)`, and returns the source server untouched when there is nothing to rename. The new test fails against the old code with `['add', 'read_file'] != ['add', 'env_read_file']`, which is the bug exactly. 2. Reset skipped adapter cleanup (Medium). `reset_async` only stopped the adapter when `is_alive()` was true, but a harness that died on its own reports False while still holding an unwaited process, open pipes and live reader threads; the next `start()` then overwrote that state and leaked it. `stop()` is contractually idempotent, so it is now called unconditionally, and also on the `start()` failure path. 3. Subprocess I/O lacked an explicit encoding (Medium). `text=True` alone decodes with the locale encoding, which is frequently ASCII in a container, while harness output is routinely not. Worse, `UnicodeDecodeError` is a `ValueError`, which the reader thread caught and exited on -- so one non-ASCII byte silently stopped stdout pumping and the turn hung until its timeout. Now `encoding="utf-8"` with `errors="replace"`, and the reader's handler is narrowed to the pipe-closed case it was meant for. 4. Timeouts were reported as crashes (Low). `HarnessTurnTimeoutError` subclasses `HarnessError`, so an adapter raising the dedicated timeout exception was labelled `harness_crashed`. It is now caught first and mapped to `turn_timeout`. Also from finding 1's root cause: mode-specific tools registered with `tool(mode=...)` live on the environment, not the FastMCP server, so the bridge cannot serve them either. They are now excluded from injection with a warning rather than advertised and then failing on call. Supporting them needs a decision about what a mode means inside a harness turn, which is left as a follow-up. Note this only ever manifested when the env's `_mode` matched the tool's -- the test sets it explicitly, since otherwise the assertion would pass vacuously. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two findings from the automated review on huggingface#1100. The third (renamed tools unreachable through the bridge) was the same root cause as a finding on huggingface#1099 and is fixed there. 1. Production turns ignored session_timeout_s (Medium). The /harness handler streamed `send_message_streaming` with no bound, while simulation mode wraps the same call in `asyncio.wait_for` inside `HarnessEnvironment._run_turn`. A hung harness therefore held its session open indefinitely, and since HarnessEnvironment is SUPPORTS_CONCURRENT_SESSIONS=False with the idle reaper off by default, the server stayed pinned at capacity. The turn is now bounded by the adapter's `session_timeout_s`, matching simulation semantics. 2. A stream that ended without TURN_COMPLETE hung the client (Medium). `send_message()` raises HarnessError in that case, but the socket loop silently went back to waiting for the next client frame, so a client blocking on the terminal event waited forever. The handler now detects it and emits a terminal ERROR event before ending the session. Both paths end the session rather than continuing, so a reconnect gets a fresh harness -- consistent with how a mid-stream crash was already handled. Adds `send_harness_error()` since three paths now emit the same terminal ERROR frame. Both new tests assert `server.active_sessions == 0` afterwards, which is the property finding 1 was really about. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
caiotheodoro
left a comment
There was a problem hiding this comment.
src/openenv/core/harness/process.py:221 — bug: read_line(timeout_s=None) doesn't do what the docstring promises ("None blocks until a line or EOF"). The reader thread's for line in stream: sink(line) (line 253) just returns when the pipe closes — no sentinel goes into the queue — so self._stdout_queue.get(timeout=None) (line 236) blocks forever past real EOF.
Repro: start a process that prints one line and exits, drain that line, then call read_line(timeout_s=None) again.
first read_line -> hello
is_running() -> False
calling read_line(timeout_s=None) on an exited process...
BUG CONFIRMED: read_line(timeout_s=None) hung for 5s past EOF instead of returning None
STILL RUNNING AFTER 12s
Wrapping the call in asyncio.wait_for lets the caller move on, but the to_thread worker stays parked in queue.get() — it isn't one of the daemon _reader_threads that stop() joins, so it outlives stop() and (being a non-daemon default-executor thread) blocks the interpreter from exiting.
Nothing in this PR calls read_line with timeout_s=None yet, but it's public API on a class future harness adapters will drive, and the contract as documented is false. Have the reader push a sentinel (or close the queue) on stream EOF and have read_line return None for it instead of relying on a bare queue.get().
|
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. |
|
@caiotheodoro, your EOF review is addressed by 1a6caa57, which is already included in this PR. The stdout reader now enqueues an EOF sentinel from its Real-subprocess regression tests cover:
These cases passed in the latest 79-test harness run. The full repository test run also passed: 2,552 passed, 120 skipped. |
|
@caiotheodoro can you please review again? |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Want higher recall? High effort reviews run extra passes and find more bugs. A team admin can switch effort levels in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7225191. Configure here.

Stack for RFC 005 — 3 of 4. Depends on #1098.
Targets
mainbecause cross-fork PRs cannot chain bases, so the diff includes #1097 and #1098. Review the top commit only:f3d90c2./harnessroute + mode wiringWhat
Makes the types from #1098 runnable: an environment that owns a harness subprocess, hands it the environment's MCP tools, and turns each
step()into one conversational turn.environment.py—HarnessAction+HarnessEnvironment(MCPEnvironment).reset()stops any live harness, enumerates and conflict-resolves env tools, starts the bridge, injects, then starts the harness (injection strictly before start, per the RFC).step()runs one turn; MCP actions keep their normal routing. Rubrics run after the turn, outside the harness's control loop — RFC 004's reward boundary.process.py—HarnessProcess: readiness-gated start, stderr-tail diagnostics, idempotent stop with SIGTERM → SIGKILL escalation over the process group.bridge.py—HarnessMCPBridge: serves the env's FastMCP tool surface over loopback HTTP for the harness to consume.Three decisions I would like challenged
1.
HarnessEnvironmentsubclassesMCPEnvironment, substituting an empty internalFastMCPwhenmcp=None.The RFC writes
mcp: Optional[FastMCP] = Nonewith a conditionalsuper().__init__(mcp), which cannot work:mcp_serveris a required positional, and skippingsuper().__init__would skipEnvironment.__init__entirely (no rubric, no transform). Substituting an empty server keeps reserved-name validation,_async_handle_list_tools()for injection, andmcp_session()integration for free. Alternative considered: subclassEnvironmentdirectly and hold aFastMCP— rejected, it means reimplementing all of the above.2.
subprocess.Popen+ reader threads, notasyncio.create_subprocess_exec.Asyncio subprocess transports are bound to their creating loop. This object has to survive three regimes: the server's long-lived loop, the sync facade (
run_async_safelyspins a fresh loop per call, so a subprocess created inreset()'s loop would be unusable instep()'s), andclose()from an executor thread. Popen plusasyncio.to_threadis loop-agnostic. Precedent:envs/julia_env/server/julia_process_pool.py.3. The bridge is a separate loopback server, not a reuse of the env server's
/mcpendpoint.Reusing
/mcpfails on two counts: it lives on the same app as/reset,/step,/state, so pointing the harness at that origin hands the agent the orchestration API — a direct violation of the RFC's security boundary and RFC 001's agents cannot reset; andWS /mcpcalls_create_session(), so the harness would talk to a different env instance than the one wrapping it. A separate tool-only server on127.0.0.1makes the boundary structural rather than filter-based.Failure handling
Turn timeouts and harness crashes become terminal observations (
done=True,metadata.error_typeofturn_timeout/harness_crashed) rather than exceptions, so a training loop scores the episode and moves on instead of unwinding. Deliberate; happy to change if reviewers prefer raising.Known limitation
HTTP
POST /resetand/stepcreate a fresh env per request and close it, which would mean a harness subprocess per HTTP call. Harness envs must be driven over the WebSocket session path. Documented in the class docstring; a route-level guard is a candidate follow-up.Verification
Real subprocesses (no mocks) for the lifecycle edge cases: startup timeout, immediate exit reporting code + stderr tail, crash mid-session, double-stop idempotency, and a child that ignores SIGTERM to prove kill escalation lands within the grace window. Real loopback HTTP for the bridge, connecting an actual
fastmcp.Clientto list and call a tool. Environment tests cover reset ordering, conflict resolution, rubric ordering, crash/timeout paths, sync-facade parity, and thatoverrides_methodsees the async overrides (which is what makes the server pick the async path). Lint clean.Subprocess behaviors live in
tests/core/scripted_harness.py— a real, lintable module spawned by mode (echo,slow-start,exit-now,crash-after-echo,ignore-sigterm), rather than code embedded in string literals.Note
Medium Risk
New core environment path spawns subprocesses and exposes env tools over loopback HTTP; failure handling and cleanup are heavily tested but this touches process lifecycle and tool-injection boundaries.
Overview
Adds the RFC 005 runtime so external agentic harnesses (OpenClaw, Claude Code, etc.) can run inside an OpenEnv container with one
step()per conversational turn.HarnessEnvironment(MCPEnvironment) lazy-starts onreset(): stop prior harness/bridge, conflict-resolve env MCP tools (env_prefix), optionally start a loopbackHarnessMCPBridge(tool-only HTTP on127.0.0.1, with renamed-tool views viabuild_bridge_server), inject tools into the adapter, then start the harness.HarnessActiondrives turns; crashes/timeouts become terminal observations (error_type:turn_timeout/harness_crashed) instead of bubbling exceptions. Rubrics run after each turn; MCPListTools/CallToolrouting is unchanged.HarnessProcessmanages long-lived CLI harnesses withPopen, reader threads, readiness checks, and SIGTERM→SIGKILL shutdown—chosen so syncrun_async_safelyand the HTTP server can share the same helper across event loops.openenv.core.harnessre-exports the turn-based API next to the existing trainer rollout API; a back-compat test assertsAgenticHarnessAdapter≠ rolloutHarnessAdapter.Broad integration tests cover real subprocess lifecycles, loopback MCP clients, reset ordering, cancellation cleanup, and mode-specific tools excluded from injection.
Reviewed by Cursor Bugbot for commit 0cc7d91. Bugbot is set up for automated code reviews on this repo. Configure here.