Skip to content
Merged
Show file tree
Hide file tree
Changes from 29 commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
3c99e0f
refactor(harness): split openenv.core.harness into a package
splusq Aug 28, 2026
01bb0ce
feat(harness): RFC 005 foundation types for agentic harnesses
splusq Aug 28, 2026
f3d90c2
feat(harness): HarnessEnvironment, subprocess helper, and MCP tool br…
splusq Aug 28, 2026
f39c662
Merge branch 'main' into rfc-005/pr1-harness-package-split
splusq Aug 31, 2026
b6e6984
Merge branch 'main' into rfc-005/pr2-harness-foundation-types
splusq Aug 31, 2026
025191d
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq Aug 31, 2026
671f5c0
Merge branch 'main' into rfc-005/pr1-harness-package-split
splusq Sep 2, 2026
933e113
Merge branch 'main' into rfc-005/pr2-harness-foundation-types
splusq Sep 2, 2026
668ab2e
fix(harness): address Bugbot review on the environment runtime
splusq Sep 2, 2026
7cda7a3
fix: refresh harness package split
burtenshaw Sep 17, 2026
c9e4316
fix: refresh harness foundation types
burtenshaw Sep 17, 2026
cf760ba
fix: refresh harness runtime
burtenshaw Sep 17, 2026
1a6caa5
fix: wake harness readers on eof
burtenshaw Sep 17, 2026
01b09e5
fix: refresh latest core state changes
burtenshaw Sep 17, 2026
5c11106
fix: refresh foundation base
burtenshaw Sep 17, 2026
8736543
fix: refresh runtime base
burtenshaw Sep 17, 2026
a81f0a2
chore: refresh latest main
burtenshaw Sep 17, 2026
4de0d41
chore: refresh foundation base
burtenshaw Sep 17, 2026
7401fe0
chore: refresh runtime base
burtenshaw Sep 17, 2026
2e622ad
Merge remote-tracking branch 'origin/main' into rfc-005/pr3-harness-e…
splusq Sep 17, 2026
4ad1fb2
fix(harness): apply transforms and clean up exited processes
splusq Sep 17, 2026
e317462
fix(harness): clean up cancelled reset and startup
splusq Sep 17, 2026
26a5043
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq Sep 23, 2026
a2bf6f6
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq Sep 24, 2026
7225191
Merge main into RFC 005 harness runtime; resolve package split conflicts
splusq Sep 25, 2026
0cc7d91
fix(harness): clean up cancelled conversational turns
splusq Sep 25, 2026
87a7772
Merge main after RFC 005 foundation types; preserve runtime exports
splusq Sep 28, 2026
d945683
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq Sep 28, 2026
079a6d5
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw Sep 29, 2026
412538b
Merge main and preserve runtime fixes from PR 1100
splusq Sep 29, 2026
86f344c
fix(harness): bound MCP bridge shutdown and retain live servers
splusq Sep 29, 2026
10e252a
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
splusq Sep 29, 2026
329e4ba
fix: retain bridges after failed shutdown
burtenshaw Sep 30, 2026
0b6627d
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw Sep 30, 2026
ad5572e
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw Sep 30, 2026
24e345a
Merge branch 'main' into rfc-005/pr3-harness-environment-runtime
burtenshaw Sep 30, 2026
6e5c36c
fix: avoid duplicate bridge shutdown wait
burtenshaw Sep 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 13 additions & 4 deletions src/openenv/core/harness/__init__.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,17 @@
# SPDX-License-Identifier: BSD-3-Clause

"""Harness helpers for training, evaluation, and wrapping external agents.
"""Harness integration helpers for training, evaluation, and wrapping agents.

This package hosts two complementary layers:

1. **Trainer-side rollout API** (``openenv.core.harness.rollout``): a harness
drives an entire episode in one call (``run_white_box``/``run_black_box``)
against a resource session. Used by ``openenv collect`` and the training
tutorials.
2. **Turn-based agentic harness API** (RFC 005): the types describing an
external harness such as OpenClaw or Claude Code, where each ``step()`` is
one conversational turn. See
2. **Turn-based agentic harness API** (RFC 005): an external harness such as
OpenClaw or Claude Code runs inside the environment container, and each
``step()`` is one conversational turn. See
[`~openenv.core.harness.environment.HarnessEnvironment`] and
[`~openenv.core.harness.adapter.AgenticHarnessAdapter`].

Both layers are importable from ``openenv.core.harness``.
Expand All @@ -23,14 +24,17 @@
HarnessStartupError,
HarnessTurnTimeoutError,
)
from .bridge import build_bridge_server, HarnessMCPBridge
from .config import HarnessConfig, HarnessTransport
from .environment import HarnessAction, HarnessEnvironment
from .events import (
events_to_metadata,
HarnessClientMessage,
HarnessEvent,
HarnessEventType,
HarnessResponse,
)
from .process import HarnessProcess
from .rollout import ( # noqa: F401 (_resolve_env_reward: private back-compat re-export)
_resolve_env_reward,
build_harness_rollout_func,
Expand Down Expand Up @@ -80,16 +84,21 @@
"build_harness_rollout_func",
# Turn-based agentic harness API (RFC 005)
"AgenticHarnessAdapter",
"HarnessAction",
"HarnessClientMessage",
"HarnessConfig",
"HarnessEnvironment",
"HarnessError",
"HarnessEvent",
"HarnessEventType",
"HarnessMCPBridge",
"HarnessNotRunningError",
"HarnessProcess",
"HarnessResponse",
"HarnessStartupError",
"HarnessTransport",
"HarnessTurnTimeoutError",
"build_bridge_server",
"events_to_metadata",
"resolve_tool_conflicts",
]
203 changes: 203 additions & 0 deletions src/openenv/core/harness/bridge.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
# SPDX-License-Identifier: BSD-3-Clause

"""Loopback MCP bridge exposing environment tools to a harness (RFC 005)."""

from __future__ import annotations

import socket
import threading
import time
from typing import Any, Optional

from .adapter import HarnessError


async def _source_tools(mcp_server: Any) -> dict[str, Any]:
"""Return {name: FastMCP tool}, compatible with FastMCP 2.x and 3.x.

Async twin of [`~openenv.core.env_server.mcp_environment.get_server_tools`],
which wraps the same calls in `run_async_safely`; awaiting directly avoids
spawning a thread when we are already on a loop.
"""
if hasattr(mcp_server, "get_tools"):
result = await mcp_server.get_tools()
if isinstance(result, dict):
return result
if hasattr(mcp_server, "list_tools"):
return {tool.name: tool for tool in await mcp_server.list_tools()}
return {}


async def build_bridge_server(mcp_server: Any, renames: dict[str, str]) -> Any:
"""
Return a FastMCP server exposing the env's tools under their injected names.

Tool-name conflict resolution can rename an environment tool before it is
injected into a harness (`read_file` -> `env_read_file`). The harness then
calls the new name, so the bridge has to answer to it: serving the source
server unchanged would advertise a name that does not resolve.

Args:
mcp_server (`FastMCP`):
The environment's own tool server.
renames (`dict[str, str]`):
Mapping of injected name to source name. Empty means no renames.

Returns:
`FastMCP`: `mcp_server` itself when there is nothing to rename,
otherwise a view of it whose tools carry the injected names.
"""
if not renames:
return mcp_server

from fastmcp import FastMCP
from fastmcp.tools.tool import Tool as FastMCPTool

source_tools = await _source_tools(mcp_server)
missing = sorted(set(renames.values()) - set(source_tools))
if missing:
raise HarnessError(
"Cannot rename tools that the MCP server does not expose: "
+ ", ".join(missing)
)

served_name_of = {source: served for served, source in renames.items()}
view = FastMCP(f"{getattr(mcp_server, 'name', 'openenv')}-harness-view")
for name, tool in source_tools.items():
served_name = served_name_of.get(name, name)
view.add_tool(
tool
if served_name == name
else FastMCPTool.from_tool(tool, name=served_name)
)
return view


class HarnessMCPBridge:
"""
Serve an in-process FastMCP server over loopback HTTP for harness use.

The bridge runs the environment's FastMCP tool server as a
streamable-HTTP ASGI app on `127.0.0.1` with an ephemeral port, inside a
daemon thread with its own event loop. It carries only the tool surface:
the harness subprocess can reach the environment's domain tools but never
OpenEnv's orchestration API (`reset`/`step`/`state`), which lives on a
different server entirely. This makes the RFC 005 security boundary
structural rather than filter-based.

Args:
mcp_server (`FastMCP`):
The environment's FastMCP server to expose.
host (`str`, *optional*, defaults to `"127.0.0.1"`):
Interface to bind. Keep this loopback-only.

Examples:

```python
bridge = HarnessMCPBridge(env.mcp_server)
url = bridge.start()
# pass url to the harness adapter's inject_tools()
bridge.stop()
```
"""

def __init__(self, mcp_server: Any, host: str = "127.0.0.1"):
self._mcp_server = mcp_server
self._host = host
self._url: Optional[str] = None
self._thread: Optional[threading.Thread] = None
self._uvicorn_server: Optional[Any] = None
self._startup_error: Optional[BaseException] = None

@property
def url(self) -> Optional[str]:
"""The bridge's MCP endpoint URL, or `None` when not running."""
return self._url

def start(self, timeout_s: float = 10.0) -> str:
"""
Start serving and return the MCP endpoint URL.

Idempotent: returns the existing URL if already running.

Args:
timeout_s (`float`, *optional*, defaults to `10.0`):
Maximum time to wait for the server to come up.

Returns:
`str` URL of the MCP endpoint, e.g. `"http://127.0.0.1:54321/mcp"`.

Raises:
[`~openenv.core.harness.adapter.HarnessError`]:
If the server fails to start within the timeout.
"""
if self._thread is not None and self._thread.is_alive():
assert self._url is not None
return self._url

import uvicorn

app = self._mcp_server.http_app()
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
sock.bind((self._host, 0))
port = sock.getsockname()[1]

config = uvicorn.Config(app, log_level="warning", lifespan="on")
server = uvicorn.Server(config)
self._uvicorn_server = server
self._startup_error = None

def _serve() -> None:
try:
server.run(sockets=[sock])
except BaseException as exc: # surfaced to start() below
self._startup_error = exc
finally:
try:
sock.close()
except OSError:
pass

thread = threading.Thread(target=_serve, daemon=True)
thread.start()
self._thread = thread

deadline = time.monotonic() + timeout_s
while not server.started:
if self._startup_error is not None or not thread.is_alive():
self._teardown()
raise HarnessError(f"MCP bridge failed to start: {self._startup_error}")
if time.monotonic() > deadline:
self.stop()
raise HarnessError(f"MCP bridge did not start within {timeout_s}s")
time.sleep(0.01)

self._url = f"http://{self._host}:{port}/mcp"
return self._url

def stop(self, timeout_s: float = 5.0) -> None:
"""
Stop the bridge server.

Idempotent: safe to call when the bridge was never started.

Args:
timeout_s (`float`, *optional*, defaults to `5.0`):
Maximum time to wait for the server thread to exit.
"""
server = self._uvicorn_server
thread = self._thread
if server is not None:
server.should_exit = True
if thread is not None and thread.is_alive():
thread.join(timeout=timeout_s)
self._teardown()

def _teardown(self) -> None:
self._url = None
self._thread = None
self._uvicorn_server = None
Comment thread
splusq marked this conversation as resolved.
Resolved


__all__ = ["build_bridge_server", "HarnessMCPBridge"]
Loading
Loading