chess_env / src /core /harness /adapter.py
sergiopaniego's picture
sergiopaniego HF Staff
Upload folder using huggingface_hub
7aca0bc verified
Raw History Blame Contribute Delete
5.49 kB
# 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",
]