Skip to content

[RFC 005] 4/4: production /harness WebSocket route and mode wiring - #1100

Open
splusq wants to merge 9 commits into
huggingface:mainfrom
splusq:rfc-005/pr4-harness-production-route
Open

[RFC 005] 4/4: production /harness WebSocket route and mode wiring#1100
splusq wants to merge 9 commits into
huggingface:mainfrom
splusq:rfc-005/pr4-harness-production-route

Conversation

@splusq

@splusq splusq commented Aug 28, 2026

Copy link
Copy Markdown

Stack for RFC 005 — 4 of 4. Depends on #1099.

  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 — environment runtime ([RFC 005] 3/4: HarnessEnvironment, subprocess helper, and MCP tool bridge #1099)
  4. 4/4 — this PR: the production surface

Where to start reviewing

Start with test_harness_websocket_over_real_server. It is the shortest complete description of the flow this PR adds — a real uvicorn server, a real WebSocket client, and a real harness subprocess, with no mocks between them. Reading it top to bottom is the production request lifecycle:

step in the test what it exercises
httpx.get("/health") / httpx.post("/reset") the mode gate: safe endpoints up, orchestration endpoints gone
ws_connect(".../harness")session_started _create_session() + reset_async() starting the harness and injecting tools
send({"type": "message", ...}) → frames one conversational turn, streamed as HarnessEvents
frames == [tool_call, tool_result, turn_complete] ordering, and the terminal-event contract
"sum=5" in ...["response"] the harness really called an env tool through the live MCP bridge
second send(...)done is True the same harness process serving turn 2, conversation context intact

Supporting cast, in the order it becomes relevant:

  • live_server — stands up the actual server on an ephemeral port; note it uses create_fastapi_app(..., mode="production"), i.e. the wiring this PR adds.
  • ScriptedCLIAdapter — what plays the harness. It reads the MCP config that inject_tools wrote, connects to the bridge with a real fastmcp.Client, and maps JSON lines to HarnessEvents. Doubles as a reference for the concrete adapters coming in RFC PR 4.
  • test_multi_turn_episode_through_real_stack — the simulation counterpart, same stack via reset()/step(), and where tool-conflict resolution (read_fileenv_read_file) and rubric ordering are asserted.

Then the implementation it drives: _register_harness_route and its gate at register_routes.

What

Exposes a harness environment directly to clients in production mode, and gives deployments a way to actually select that mode.

/harness is registered only when mode is PRODUCTION and the env factory produces a HarnessEnvironment. Connecting opens a session and resets the env (starting the harness, injecting tools); each {"type": "message", "content": ...} frame runs one turn, streamed back as HarnessEvent frames terminated by turn_complete.

Modelled on WS /mcp, not on the RFC pseudocode

The RFC sketch calls self._env_factory() directly. This handler goes through _create_session() instead, so capacity limits, the AsyncExitStack, per-session executors and the idle reaper all apply — the RFC version would bypass all of it. Session activity is also touched per streamed event, so a long turn is not reaped as idle mid-stream.

Error handling: malformed frames get a WSErrorResponse and the connection stays usable; an adapter crash streams a terminal error event and ends the session, since harness state after a crash is undefined and a reconnect gets a fresh one.

Mode wiring

create_app / create_fastapi_app / the web-interface factory take a keyword-only mode, resolved from OPENENV_MODE when omitted. Previously create_fastapi_app hardcoded server.register_routes(app), so nothing outside tests could ever select production — the only in-repo caller passing PRODUCTION was a test. Default is unchanged (simulation), and the env-var name mirrors the existing OPENENV_CLIENT_MODE convention on the client side.

Notes

  • Lazy import for harness-env detection. openenv.core.harness imports env_server modules, so a top-level import back would be circular. Detection may instantiate the factory once to probe it, which is safe because constructing a HarnessEnvironment starts nothing — asserted by a test.

Verification

676 passed, 2 skipped   (tests/core/)
1590 passed, 98 skipped (full CI-equivalent)

Route tests cover the gate in both directions (absent in simulation, absent in production for a non-harness env), that /reset///step///state stay absent while /health works, frame ordering, two turns reusing one adapter, malformed-frame recovery, mid-stream crash, disconnect cleanup with active_sessions == 0, and capacity rejection. All three mode entry points are exercised: enum, string, and OPENENV_MODE.

test_agentic_harness_e2e.py is the one worth reading. Nothing on the critical path is faked: a real subprocess harness reads the injected MCP config, connects to the live bridge with a real MCP client, calls an env tool, and streams turns back — driven both through the simulation reset()/step() API and through a real uvicorn server's /harness socket with a real WebSocket client.

Out of scope (follow-ups)

Concrete adapters for OpenClaw / Claude Code (RFC PR 4), an example env and user docs (RFC PR 5), a HarnessEnvClient (streaming N frames per message does not fit EnvClient's one-request-one-response transport), and a guard rejecting harness envs on the per-request HTTP /reset///step path.

Also worth a small doc PR: the RFC text is stale in several places this stack had to work around (ToolDefinitionTool, env_factory=env=, _mcp_servermcp_server, run_until_completerun_async_safely).


Note

High Risk
Introduces a production WebSocket API, subprocess/bridge lifecycle, and mode gating that changes which orchestration routes are exposed—security- and capacity-sensitive server behavior.

Overview
Adds the RFC 005 production client surface and the plumbing to turn it on: when ServerMode.PRODUCTION and the env factory yields a HarnessEnvironment, the server registers /harness — a WebSocket where connect creates a session, reset_async() starts the harness and injects tools, and each {"type":"message","content":...} frame streams one turn as HarnessEvent JSON ending in turn_complete, with turn wall-clock limits and session cleanup on crash or disconnect.

Mode selection is no longer hardcoded to simulation: create_app, create_fastapi_app, and the web-interface factory accept a keyword-only mode (default from OPENENV_MODE, else simulation) and pass it into register_routes.

The harness package is split for the two APIs: trainer rollout helpers move to openenv.core.harness.rollout (re-exported from the package root for back-compat); new modules cover turn-based harnesses (HarnessEnvironment, AgenticHarnessAdapter, loopback HarnessMCPBridge, HarnessProcess, events/config/tool conflict resolution). collect imports rollout from the new module path.

Reviewed by Cursor Bugbot for commit dc3cf59. Bugbot is set up for automated code reviews on this repo. Configure here.

splusq and others added 4 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>
Exposes a harness environment directly to clients in production mode, and
gives deployments a way to actually select that mode.

`/harness` is registered only when mode is PRODUCTION *and* the env
factory produces a `HarnessEnvironment`. Connecting opens a session and
resets the env (starting the harness and injecting tools); each
`{"type": "message", "content": ...}` frame runs one turn, streamed back
as HarnessEvent frames terminated by turn_complete. Malformed frames get
a WSErrorResponse without dropping the connection; an adapter crash
streams a terminal error event and ends the session, since harness state
after a crash is undefined.

The handler is modelled on the existing WS /mcp handler rather than the
RFC's pseudocode: it goes through `_create_session()` so capacity limits,
the AsyncExitStack, per-session executors and the idle reaper all apply.
The RFC sketch calls the factory directly and would bypass all of it.
Session activity is touched per streamed event so a long turn is not
reaped as idle.

Mode wiring: `create_app` / `create_fastapi_app` (and the web-interface
factory) take a keyword-only `mode`, resolved from `OPENENV_MODE` when
omitted. Previously `create_fastapi_app` hardcoded `register_routes(app)`,
so nothing outside tests could ever select production. Default is
unchanged (simulation), mirroring the existing `OPENENV_CLIENT_MODE`
convention on the client side.

Two notes for review:

- Harness-env detection uses a lazy import of `HarnessEnvironment` inside
  the method: `openenv.core.harness` imports env_server modules, so a
  top-level import would be circular. Detection may instantiate the
  factory once to probe it, which is safe because constructing a
  HarnessEnvironment starts nothing -- asserted by a test.
- `websocket.close()` on an already-gone client raises RuntimeError under
  TestClient but WebSocketDisconnect under a real ASGI server; both are
  now caught. Found by the end-to-end test, which drives a real uvicorn
  server. The pre-existing /mcp and /ws handlers have the same latent gap
  and are deliberately left untouched here.

Includes the end-to-end test for the whole stack: a real subprocess
harness that reads the injected MCP config, calls an env tool over the
live bridge, and streams turns -- exercised through both the simulation
step() API and a real uvicorn server's /harness socket.

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

Fix All in Cursor

❌ 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 58dd957. Configure here.

Comment thread src/openenv/core/harness/environment.py
Comment thread src/openenv/core/env_server/http_server.py
Comment thread src/openenv/core/env_server/http_server.py
splusq and others added 3 commits September 2, 2026 10:07
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>
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