Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 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
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
60 changes: 52 additions & 8 deletions src/openenv/core/harness/__init__.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
# SPDX-License-Identifier: BSD-3-Clause

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

The trainer-side rollout API now lives in ``openenv.core.harness.rollout``:
a harness drives an entire episode in one ``run_white_box``/``run_black_box``
call against a resource session. It is re-exported here unchanged, so
``from openenv.core.harness import ...`` keeps working exactly as before.
This package hosts two complementary layers:

Splitting the package this way makes room for the RFC 005 turn-based agentic
harness layer to land alongside it in sibling modules, rather than growing a
single monolithic ``__init__``.
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): 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``.
"""

from .adapter import (
AgenticHarnessAdapter,
HarnessError,
HarnessNotRunningError,
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 All @@ -35,8 +58,10 @@
TraceEntry,
VerifyResult,
)
from .tools import resolve_tool_conflicts

__all__ = [
# Trainer-side rollout API (openenv.core.harness.rollout)
"CLIHarnessAdapter",
"HarnessAdapter",
"HarnessRolloutResult",
Expand All @@ -57,4 +82,23 @@
"TraceEntry",
"VerifyResult",
"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",
]
168 changes: 168 additions & 0 deletions src/openenv/core/harness/adapter.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# SPDX-License-Identifier: BSD-3-Clause

"""Turn-based adapter interface for external agentic harnesses (RFC 005)."""

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import AsyncIterator, Optional

from ..env_server.mcp_types import Tool
from .config import HarnessConfig
from .events import HarnessEvent, HarnessEventType, HarnessResponse


class HarnessError(Exception):
"""Base error for agentic harness failures."""


class HarnessStartupError(HarnessError):
"""The harness process failed to start or become ready in time."""


class HarnessNotRunningError(HarnessError):
"""An operation required a running harness but none was alive."""


class HarnessTurnTimeoutError(HarnessError):
"""A single conversational turn exceeded its wall-clock budget."""


class AgenticHarnessAdapter(ABC):
"""
Abstract adapter for a turn-based external agentic harness.

Subclass this to integrate a harness such as OpenClaw or Claude Code. The
adapter owns the harness process lifecycle and communication; the harness
itself is a long-lived process that maintains conversation context across
turns. Each `send_message()` call is one conversational turn: the harness
runs its internal ReAct loop and returns when it has a response.

Distinct from the trainer-side rollout
[`~openenv.core.harness.rollout.HarnessAdapter`], which drives an entire
episode in a single call.

Attributes:
BUILTIN_TOOL_NAMES (`frozenset[str]`):
Names of the harness's built-in tools, used to detect conflicts
with injected environment tools. Concrete adapters override this.
"""

BUILTIN_TOOL_NAMES: frozenset[str] = frozenset()

def __init__(self, config: HarnessConfig):
self.config = config

@abstractmethod
async def start(self, working_directory: str) -> None:
"""
Start the harness process.

Args:
working_directory (`str`):
Path where the harness should operate.

Raises:
[`~openenv.core.harness.adapter.HarnessStartupError`]:
If the process fails to start or become ready in time.
"""
...

@abstractmethod
async def stop(self) -> None:
"""
Stop the harness process and clean up resources.

Implementations must be idempotent: calling `stop()` on an already
stopped (or never started) adapter must succeed silently.
"""
...

@abstractmethod
async def inject_tools(
self, tools: list[Tool], bridge_url: Optional[str] = None
) -> None:
"""
Inject environment MCP tool definitions into the harness configuration.

Called before `start()` so the harness discovers the tools at startup.
The mechanism is harness-specific (config file, CLI flags, environment
variables).

Args:
tools (`list[Tool]`):
Conflict-resolved environment tool definitions to inject.
bridge_url (`str`, *optional*):
URL of the MCP bridge serving these tools, when the
environment exposes one.
"""
...

@abstractmethod
def send_message_streaming(self, message: str) -> AsyncIterator[HarnessEvent]:
"""
Send a message and stream intermediate events for one turn.

Yields events as the harness processes the turn (tool calls, LLM
chunks, text output). The final event must be a
[`~openenv.core.harness.events.HarnessEvent`] of type `TURN_COMPLETE`
whose data carries the turn's `response` and optionally `done`.

Args:
message (`str`):
The user message for this conversational turn.

Yields:
[`~openenv.core.harness.events.HarnessEvent`] instances.
"""
...

@abstractmethod
async def is_alive(self) -> bool:
"""Check whether the harness process is still running."""
...

async def send_message(self, message: str) -> HarnessResponse:
"""
Send a message and collect the complete response for one turn.

Drains `send_message_streaming()` and assembles a
[`~openenv.core.harness.events.HarnessResponse`] from the terminal
`TURN_COMPLETE` event.

Args:
message (`str`):
The user message for this conversational turn.

Returns:
[`~openenv.core.harness.events.HarnessResponse`] with the text
response, all turn events, and the harness's done signal.

Raises:
[`~openenv.core.harness.adapter.HarnessError`]:
If the event stream ends without a `TURN_COMPLETE` event.
"""
events: list[HarnessEvent] = []
async for event in self.send_message_streaming(message):
events.append(event)

if not events or events[-1].type is not HarnessEventType.TURN_COMPLETE:
raise HarnessError(
"harness event stream ended without a TURN_COMPLETE event"
)

terminal = events[-1]
return HarnessResponse(
response=str(terminal.data.get("response", "")),
events=events,
done=bool(terminal.data.get("done", False)),
)


__all__ = [
"AgenticHarnessAdapter",
"HarnessError",
"HarnessNotRunningError",
"HarnessStartupError",
"HarnessTurnTimeoutError",
]
Loading