[RFC 005] 3/4: HarnessEnvironment, subprocess helper, and MCP tool bridge - #1099
Open
splusq wants to merge 5 commits into
Open
[RFC 005] 3/4: HarnessEnvironment, subprocess helper, and MCP tool bridge#1099splusq wants to merge 5 commits into
splusq wants to merge 5 commits into
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>
This was referenced Aug 28, 2026
splusq
marked this pull request as ready for review
August 31, 2026 21:23
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 4 potential issues.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 025191d. Configure here.
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>
splusq
added a commit
to splusq/OpenEnv
that referenced
this pull request
Sep 2, 2026
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

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
Introduces subprocess lifecycle, loopback MCP serving, and new environment reset/step semantics that training loops depend on; failures are softened to terminal observations rather than exceptions.
Overview
Adds the RFC 005 turn-based runtime on top of the harness package split: external agentic harnesses run as long-lived subprocesses, with each
HarnessActionstep()treated as one conversational turn.HarnessEnvironment(MCPEnvironment) owns episode lifecycle:reset()always stops prior state, conflict-resolves env MCP tools (resolve_tool_conflicts), starts a loopback-onlyHarnessMCPBridgewhen tools exist (withbuild_bridge_serverso injected renames likeenv_read_fileactually resolve), theninject_toolsbeforestart. Turns stream events into observation metadata; rubrics run after the harness finishes the turn. Crashes and turn timeouts return terminal observations (done=True,error_type) instead of raising, so training can score and continue.HarnessProcessprovides loop-agnostic stdio subprocess management (Popen + reader threads, UTF-8, SIGTERM→SIGKILL on the process group).collect.pynow imports rollout types fromharness.rollout; the package root still re-exports the trainer rollout API and_resolve_env_rewardfor back-compat.Broad test coverage adds real subprocess and loopback HTTP integration via
scripted_harness.py.Reviewed by Cursor Bugbot for commit 668ab2e. Bugbot is set up for automated code reviews on this repo. Configure here.