Skip to content

[RFC 005] 3/4: HarnessEnvironment, subprocess helper, and MCP tool bridge - #1099

Open
splusq wants to merge 5 commits into
huggingface:mainfrom
splusq:rfc-005/pr3-harness-environment-runtime
Open

[RFC 005] 3/4: HarnessEnvironment, subprocess helper, and MCP tool bridge#1099
splusq wants to merge 5 commits into
huggingface:mainfrom
splusq:rfc-005/pr3-harness-environment-runtime

Conversation

@splusq

@splusq splusq commented Aug 28, 2026

Copy link
Copy Markdown

Stack for RFC 005 — 3 of 4. Depends on #1098.

Targets main because cross-fork PRs cannot chain bases, so the diff includes #1097 and #1098. Review the top commit only: f3d90c2.

  1. 1/4 — package split ([RFC 005] 1/4: split openenv.core.harness into a package #1097)
  2. 2/4 — foundation types ([RFC 005] 2/4: foundation types for agentic harnesses #1098)
  3. 3/4 — this PR: the environment runtime
  4. 4/4 — production /harness route + mode wiring

What

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.pyHarnessAction + 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.pyHarnessProcess: readiness-gated start, stderr-tail diagnostics, idempotent stop with SIGTERM → SIGKILL escalation over the process group.
  • bridge.pyHarnessMCPBridge: serves the env's FastMCP tool surface over loopback HTTP for the harness to consume.

Three decisions I would like challenged

1. HarnessEnvironment subclasses MCPEnvironment, substituting an empty internal FastMCP when mcp=None.
The RFC writes mcp: Optional[FastMCP] = None with a conditional super().__init__(mcp), which cannot work: mcp_server is a required positional, and skipping super().__init__ would skip Environment.__init__ entirely (no rubric, no transform). Substituting an empty server keeps reserved-name validation, _async_handle_list_tools() for injection, and mcp_session() integration for free. Alternative considered: subclass Environment directly and hold a FastMCP — rejected, it means reimplementing all of the above.

2. subprocess.Popen + reader threads, not asyncio.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_safely spins a fresh loop per call, so a subprocess created in reset()'s loop would be unusable in step()'s), and close() from an executor thread. Popen plus asyncio.to_thread is 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 /mcp endpoint.
Reusing /mcp fails 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; and WS /mcp calls _create_session(), so the harness would talk to a different env instance than the one wrapping it. A separate tool-only server on 127.0.0.1 makes the boundary structural rather than filter-based.

Failure handling

Turn timeouts and harness crashes become terminal observations (done=True, metadata.error_type of turn_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 /reset and /step create 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

76 passed

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.Client to list and call a tool. Environment tests cover reset ordering, conflict resolution, rubric ordering, crash/timeout paths, sync-facade parity, and that overrides_method sees 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 HarnessAction step() 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-only HarnessMCPBridge when tools exist (with build_bridge_server so injected renames like env_read_file actually resolve), then inject_tools before start. 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.

HarnessProcess provides loop-agnostic stdio subprocess management (Popen + reader threads, UTF-8, SIGTERM→SIGKILL on the process group). collect.py now imports rollout types from harness.rollout; the package root still re-exports the trainer rollout API and _resolve_env_reward for 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.

splusq and others added 3 commits August 28, 2026 13:41
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>
@splusq
splusq marked this pull request as ready for review August 31, 2026 21:23

@cursor cursor Bot left a comment

Copy link
Copy Markdown

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 4 potential issues.

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 025191d. Configure here.

Comment thread src/openenv/core/harness/environment.py
Comment thread src/openenv/core/harness/process.py
Comment thread src/openenv/core/harness/environment.py Outdated
Comment thread src/openenv/core/harness/environment.py
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>
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.

1 participant