Team Ai
Apppublic

openenv/coding_env

sourceHugging Faceupdated 3mo agoView on Hugging Face
21likes
http_server.py1647 linesDownload Raw Back to env_server
1# Copyright (c) Meta Platforms, Inc. and affiliates.2# All rights reserved.3#4# This source code is licensed under the BSD-style license found in the5# LICENSE file in the root directory of this source tree.6 7"""8HTTP server wrapper for Environment instances.9 10This module provides utilities to wrap any Environment subclass and expose it11over HTTP and WebSocket endpoints that EnvClient can consume.12"""13 14from __future__ import annotations15 16import asyncio17import inspect18import json19import logging20import os21import time22import uuid23from concurrent.futures import ThreadPoolExecutor24from contextlib import AsyncExitStack25from typing import Any, AsyncContextManager, Callable, cast, Dict, Optional, Type26 27_MISSING = object()28 29from fastapi import (30    Body,31    FastAPI,32    HTTPException,33    Request,34    status,35    WebSocket,36    WebSocketDisconnect,37)38from pydantic import ValidationError39 40from .interfaces import Environment41from .mcp_environment import get_server_tools42from .mcp_types import (43    JsonRpcErrorCode,44    JsonRpcRequest,45    JsonRpcResponse,46    McpMethod,47    WSMCPMessage,48    WSMCPResponse,49)50from .route_config import GetEndpointConfig, register_get_endpoints51from .serialization import deserialize_action, serialize_observation52from .types import (53    Action,54    ConcurrencyConfig,55    EnvironmentMetadata,56    HealthResponse,57    HealthStatus,58    Observation,59    ResetRequest,60    ResetResponse,61    SchemaResponse,62    ServerCapacityStatus,63    ServerMode,64    SessionInfo,65    State,66    StepRequest,67    StepResponse,68    WSCloseMessage,69    WSErrorCode,70    WSErrorResponse,71    WSObservationResponse,72    WSResetMessage,73    WSStateMessage,74    WSStateResponse,75    WSStepMessage,76)77 78 79def _make_json_serializable(obj: Any) -> Any:80    """81    Convert an object to a JSON-serializable form.82 83    Handles Pydantic models, dataclasses, and other common types.84 85    Args:86        obj: The object to convert87 88    Returns:89        A JSON-serializable representation of the object90    """91    if obj is None:92        return None93    if isinstance(obj, (str, int, float, bool)):94        return obj95    if isinstance(obj, (list, tuple)):96        return [_make_json_serializable(item) for item in obj]97    if isinstance(obj, dict):98        return {k: _make_json_serializable(v) for k, v in obj.items()}99    if hasattr(obj, "model_dump"):100        # Pydantic model101        return obj.model_dump()102    if hasattr(obj, "__dict__"):103        # Object with __dict__104        return {k: _make_json_serializable(v) for k, v in obj.__dict__.items()}105    # Fallback to string representation106    return str(obj)107 108 109from .exceptions import (110    ConcurrencyConfigurationError,111    EnvironmentFactoryError,112    SessionCapacityError,113)114 115 116class HTTPEnvServer:117    """118    HTTP server wrapper for Environment instances.119 120    This class wraps an Environment and exposes its reset(), step(), and state121    methods as HTTP and WebSocket endpoints compatible with EnvClient.122 123    The server expects:124    - Action deserialization: Converts JSON dict to Action subclass125    - Observation serialization: Converts Observation subclass to JSON dict126 127    Example:128        >>> from core.env_server import HTTPEnvServer129        >>> from envs.coding_env.server import CodeExecutionEnvironment130        >>> from envs.coding_env.models import CodeAction, CodeObservation131        >>>132        >>> # Pass environment class (factory pattern)133        >>> server = HTTPEnvServer(134        ...     env=CodeExecutionEnvironment,135        ...     action_cls=CodeAction,136        ...     observation_cls=CodeObservation,137        ...     max_concurrent_envs=4,138        ... )139        >>>140        >>> # Register routes with FastAPI141        >>> from fastapi import FastAPI142        >>> app = FastAPI()143        >>> server.register_routes(app)144    """145 146    def __init__(147        self,148        env: Callable[[], Environment],149        action_cls: Type[Action],150        observation_cls: Type[Observation],151        max_concurrent_envs: Optional[int] = None,152        concurrency_config: Optional[ConcurrencyConfig] = None,153    ):154        """155        Initialize HTTP server wrapper.156 157        Args:158            env: Environment factory (callable) that creates new instances.159                 Will be called to create a new environment for each WebSocket session.160            action_cls: The Action subclass this environment expects161            observation_cls: The Observation subclass this environment returns162            max_concurrent_envs: Maximum number of concurrent WebSocket sessions.163                                 Mutually exclusive with concurrency_config.164            concurrency_config: Optional ConcurrencyConfig for advanced concurrency settings.165                                Mutually exclusive with max_concurrent_envs.166 167        Raises:168            ValueError: If both max_concurrent_envs and concurrency_config are provided.169            ConcurrencyConfigurationError: If max_concurrent_envs > 1 for an170                environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS.171        """172        # Validate that env is callable173        if not callable(env):174            raise TypeError(175                f"env must be a callable (class or factory function), got {type(env)}. "176                f"Pass the environment class (e.g., MyEnvironment) not an instance (e.g., MyEnvironment())."177            )178 179        self._env_factory: Callable[[], Environment] = env180 181        # Handle concurrency configuration182        if max_concurrent_envs is not None and concurrency_config is not None:183            raise ValueError(184                "Cannot specify both 'max_concurrent_envs' and 'concurrency_config'. "185                "Please use only one method to configure concurrency."186            )187 188        if concurrency_config is not None:189            self._concurrency_config = concurrency_config190        elif max_concurrent_envs is not None:191            self._concurrency_config = ConcurrencyConfig(192                max_concurrent_envs=max_concurrent_envs,193                session_timeout=None,194            )195        else:196            # Default configuration197            self._concurrency_config = ConcurrencyConfig(198                max_concurrent_envs=1,199                session_timeout=None,200            )201 202        self._max_concurrent_envs = self._concurrency_config.max_concurrent_envs203 204        # Validate concurrency configuration205        self._validate_concurrency_safety()206 207        self.action_cls = action_cls208        self.observation_cls = observation_cls209 210        # Session management for WebSocket connections211        self._sessions: Dict[str, Optional[Environment]] = {}212        self._session_executors: Dict[str, ThreadPoolExecutor] = {}213        self._session_stacks: Dict[str, AsyncExitStack] = {}214        self._session_info: Dict[str, SessionInfo] = {}215        self._session_lock = asyncio.Lock()216 217        # Create thread pool for running sync code in async context218        # This is needed for environments using sync libraries (e.g., Playwright)219        self._executor = ThreadPoolExecutor(max_workers=32)220 221        # Idle session reaper configuration.222        # Timeout is taken from ConcurrencyConfig.session_timeout;223        # None means no timeout (default — reaper is a no-op).224        self._session_idle_timeout_s: Optional[float] = (225            self._concurrency_config.session_timeout226        )227        self._reaper_task: Optional[asyncio.Task[None]] = None228 229    def _validate_concurrency_safety(self) -> None:230        """231        Validate that the environment supports the configured concurrency level.232 233        Raises:234            ConcurrencyConfigurationError: If max_concurrent_envs > 1 for an235                environment that is not marked as SUPPORTS_CONCURRENT_SESSIONS.236        """237        if self._max_concurrent_envs <= 1:238            return239 240        if inspect.isclass(self._env_factory):241            env_cls = self._env_factory242        else:243            _temp_env = self._env_factory()244            env_cls = type(_temp_env)245            _temp_env.close()246            del _temp_env247 248        if not getattr(env_cls, "SUPPORTS_CONCURRENT_SESSIONS", False):249            raise ConcurrencyConfigurationError(250                environment_name=env_cls.__name__,251                max_concurrent_envs=self._max_concurrent_envs,252            )253 254    def get_capacity_status(self) -> ServerCapacityStatus:255        """256        Get the current capacity status of the server.257 258        Returns:259            ServerCapacityStatus with current session counts and availability.260        """261        return ServerCapacityStatus.from_counts(262            active=len(self._sessions),263            max_sessions=self._max_concurrent_envs,264        )265 266    async def _run_sync_in_thread_pool(267        self, func: Callable[..., Observation], *args, **kwargs268    ) -> Observation:269        """Run a synchronous function in the thread pool executor."""270        loop = asyncio.get_event_loop()271        return await loop.run_in_executor(self._executor, lambda: func(*args, **kwargs))272 273    def _get_valid_kwargs(274        self,275        sig: inspect.Signature,276        kwargs: Dict[str, Any],277        skip_params: Optional[set[str]] = None,278    ) -> Dict[str, Any]:279        """Filter kwargs to only include parameters accepted by the function signature."""280        if skip_params is None:281            skip_params = set()282 283        valid_kwargs = {}284 285        has_kwargs = any(286            p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()287        )288 289        for k, v in kwargs.items():290            if k in sig.parameters or has_kwargs:291                if k not in skip_params:292                    valid_kwargs[k] = v293 294        return valid_kwargs295 296    async def _create_session(self) -> tuple[str, Environment]:297        """298        Create a new WebSocket session with its own environment instance.299 300        Returns:301            Tuple of (session_id, environment)302 303        Raises:304            SessionCapacityError: If max concurrent sessions reached305            EnvironmentFactoryError: If the factory fails to create an environment306        """307        async with self._session_lock:308            if len(self._sessions) >= self._max_concurrent_envs:309                raise SessionCapacityError(310                    active_sessions=len(self._sessions),311                    max_sessions=self._max_concurrent_envs,312                )313 314            session_id = str(uuid.uuid4())315            current_time = time.time()316 317            # Create executor and reserve slot so capacity is not exceeded while318            # we create the env outside the lock (avoids blocking other sessions)319            executor = ThreadPoolExecutor(max_workers=1)320            self._session_executors[session_id] = executor321            self._sessions[session_id] = None  # placeholder until env is ready322 323        try:324            # Create environment in the executor thread (outside lock)325            loop = asyncio.get_event_loop()326            env = await loop.run_in_executor(executor, self._env_factory)327        except Exception as e:328            async with self._session_lock:329                executor.shutdown(wait=False)330                self._session_executors.pop(session_id, None)331                self._sessions.pop(session_id, None)332            factory_name = getattr(333                self._env_factory, "__name__", str(self._env_factory)334            )335            raise EnvironmentFactoryError(factory_name) from e336 337        # Hold the MCP session open for the lifetime of this session,338        # matching the WebSocket path's AsyncExitStack pattern.  This339        # prevents per-request MCP transport teardown/reconnection and340        # preserves FastMCP session state (ctx.set_state / ctx.get_state)341        # across HTTP calls within the same OpenEnv session.342        stack = AsyncExitStack()343        try:344            mcp_session_factory = getattr(env, "mcp_session", None)345            if callable(mcp_session_factory):346                mcp_session_cm = cast(AsyncContextManager[Any], mcp_session_factory())347                await stack.enter_async_context(mcp_session_cm)348        except Exception:349            # MCP transport failed to start — clean up the reserved slot,350            # the env, and the executor so they don't leak permanently351            # against _max_concurrent_envs.352            await stack.aclose()  # best-effort353            async with self._session_lock:354                self._sessions.pop(session_id, None)355                self._session_executors.pop(session_id, None)356                self._session_info.pop(session_id, None)357            await self._cleanup_session_resources(env, executor)358            raise359 360        async with self._session_lock:361            self._sessions[session_id] = env362            self._session_stacks[session_id] = stack363            now = time.time()364            self._session_info[session_id] = SessionInfo(365                session_id=session_id,366                created_at=current_time,367                last_activity_at=now,368                step_count=0,369                environment_type=type(env).__name__,370            )371 372        return session_id, env373 374    async def _destroy_session(self, session_id: str) -> None:375        """376        Destroy a WebSocket session and cleanup resources.377 378        Args:379            session_id: The session ID to destroy380        """381        async with self._session_lock:382            env = self._sessions.pop(session_id, None)383            executor = self._session_executors.pop(session_id, None)384            stack = self._session_stacks.pop(session_id, None)385            self._session_info.pop(session_id, None)386 387        await self._cleanup_session_resources(env, executor, stack)388 389    async def _cleanup_session_resources(390        self,391        env: Optional[Environment],392        executor: Optional[ThreadPoolExecutor],393        stack: Optional[AsyncExitStack] = None,394    ) -> None:395        """Close an environment and shut down its executor (best-effort)."""396        # Close the MCP session stack first — this gracefully exits the397        # mcp_session() context (and the underlying FastMCP Client session)398        # before we tear down the environment references.399        if stack is not None:400            try:401                await stack.aclose()402            except Exception:403                pass  # Best effort cleanup404 405        # Run close() in the same executor where the env was created406        # This is required for thread-sensitive libraries like Playwright/greenlet407        if env is not None:408            if executor is not None:409                try:410                    loop = asyncio.get_event_loop()411                    await loop.run_in_executor(executor, env.close)412                except Exception:413                    # If executor close fails, try direct close as fallback414                    try:415                        env.close()416                    except Exception:417                        pass  # Best effort cleanup418            else:419                try:420                    env.close()421                except Exception:422                    pass  # Best effort cleanup423 424        # Shutdown executor after close is done425        if executor is not None:426            executor.shutdown(wait=False)427 428    def _update_session_activity(429        self, session_id: str, increment_step: bool = False430    ) -> None:431        """432        Update session activity timestamp and optionally increment step count.433 434        Args:435            session_id: The session ID to update436            increment_step: If True, increment the step count437        """438        if session_id in self._session_info:439            self._session_info[session_id].last_activity_at = time.time()440            if increment_step:441                self._session_info[session_id].step_count += 1442 443    async def _reap_idle_sessions(self) -> None:444        """Background task that periodically destroys sessions idle beyond the timeout."""445        timeout = self._session_idle_timeout_s446        if timeout is None:447            return  # no timeout configured — noop448        interval = max(timeout / 4, 5.0)  # check frequently enough449        while True:450            try:451                await asyncio.sleep(interval)452                now = time.time()453                stale_ids: list[str] = []454                async with self._session_lock:455                    for sid, info in self._session_info.items():456                        if now - info.last_activity_at > timeout:457                            stale_ids.append(sid)458                for sid in stale_ids:459                    # Re-check under lock: activity may have arrived since460                    # the snapshot was taken, making this session active again.461                    # Refresh `now` so slow _destroy_session calls don't cause462                    # subsequent entries to be validated against a stale clock.463                    now = time.time()464                    async with self._session_lock:465                        info = self._session_info.get(sid)466                        if info is None or (now - info.last_activity_at) <= timeout:467                            continue468                    await self._destroy_session(sid)469            except asyncio.CancelledError:470                break471            except Exception as exc:472                logging.getLogger(__name__).warning(473                    "Idle-session reaper encountered an error (will retry): %s",474                    exc,475                )476 477    def _start_reaper(self) -> None:478        """Start the idle-session reaper if a timeout is configured."""479        if self._session_idle_timeout_s is not None and self._reaper_task is None:480            self._reaper_task = asyncio.create_task(self._reap_idle_sessions())481 482    def _stop_reaper(self) -> None:483        """Cancel the reaper background task."""484        if self._reaper_task is not None:485            self._reaper_task.cancel()486            self._reaper_task = None487 488    def get_session_info(self, session_id: str) -> Optional[SessionInfo]:489        """490        Get information about a specific session.491 492        Args:493            session_id: The session ID to query494 495        Returns:496            SessionInfo if the session exists, None otherwise497        """498        return self._session_info.get(session_id)499 500    async def _run_in_session_executor(501        self, session_id: str, func: Callable[..., Observation], *args, **kwargs502    ) -> Observation:503        """Run a synchronous function in the session's thread pool executor."""504        executor = self._session_executors.get(session_id, self._executor)505        loop = asyncio.get_event_loop()506        return await loop.run_in_executor(executor, lambda: func(*args, **kwargs))507 508    @property509    def active_sessions(self) -> int:510        """Return the number of active WebSocket sessions."""511        return len(self._sessions)512 513    @property514    def max_concurrent_envs(self) -> int:515        """Return the maximum number of concurrent environments."""516        return self._max_concurrent_envs517 518    @property519    def is_concurrency_safe(self) -> bool:520        """Return whether the environment is marked as concurrency safe."""521        import inspect522 523        if inspect.isclass(self._env_factory):524            return getattr(self._env_factory, "SUPPORTS_CONCURRENT_SESSIONS", False)525        else:526            _temp_env = self._env_factory()527            result = getattr(_temp_env, "SUPPORTS_CONCURRENT_SESSIONS", False)528            _temp_env.close()529            del _temp_env530            return result531 532    @property533    def concurrency_config(self) -> ConcurrencyConfig:534        """Return the concurrency configuration."""535        return self._concurrency_config536 537    def register_routes(538        self, app: FastAPI, mode: ServerMode | str = ServerMode.SIMULATION539    ) -> None:540        """541        Register HTTP routes on a FastAPI application.542 543        Args:544            app: FastAPI application instance545            mode: Server mode - either SIMULATION or PRODUCTION (or string equivalents).546                  In production mode, simulation control endpoints (/reset, /step, /state)547                  are NOT registered. Only safe endpoints (/health, /schema, /metadata, /ws)548                  are available. Defaults to SIMULATION for backwards compatibility.549 550        Raises:551            ValueError: If mode is not a valid ServerMode or string equivalent.552        """553        # Convert string to ServerMode enum for backwards compatibility554        if isinstance(mode, str):555            try:556                mode = ServerMode(mode.lower())557            except ValueError:558                valid_modes = [m.value for m in ServerMode]559                raise ValueError(560                    f"Invalid mode: '{mode}'. Must be one of: {valid_modes}"561                )562 563        # Wire up idle-session reaper lifecycle via app events564        server_ref = self565 566        async def _start_session_reaper() -> None:567            server_ref._start_reaper()568 569        async def _stop_session_reaper() -> None:570            server_ref._stop_reaper()571 572        if not getattr(app.router, "_openenv_reaper_registered", False):573            app.router.on_startup.append(_start_session_reaper)574            app.router.on_shutdown.append(_stop_session_reaper)575            app.router._openenv_reaper_registered = True  # type: ignore[attr-defined]576 577        # Helper function to handle reset endpoint578        async def reset_handler(579            request: ResetRequest = Body(default_factory=ResetRequest),580        ) -> ResetResponse:581            """Reset endpoint - returns initial observation."""582            _env = self._env_factory()583 584            try:585                kwargs = request.model_dump(exclude_unset=True)586 587                is_async = _env.reset_async.__func__ is not Environment.reset_async588 589                if is_async:590                    sig = inspect.signature(_env.reset_async)591                else:592                    sig = inspect.signature(_env.reset)593                valid_kwargs = self._get_valid_kwargs(sig, kwargs)594 595                if is_async:596                    observation = await _env.reset_async(**valid_kwargs)597                else:598                    observation = await self._run_sync_in_thread_pool(599                        _env.reset, **valid_kwargs600                    )601                return ResetResponse(**serialize_observation(observation))602            finally:603                _env.close()604 605        # Helper function to handle step endpoint606        async def step_handler(request: StepRequest) -> StepResponse:607            """Step endpoint - executes action and returns observation."""608            action_data = request.action609 610            try:611                action = deserialize_action(action_data, self.action_cls)612            except ValidationError as e:613                raise HTTPException(614                    status_code=status.HTTP_422_UNPROCESSABLE_CONTENT, detail=e.errors()615                )616 617            _env = self._env_factory()618 619            try:620                kwargs = request.model_dump(exclude_unset=True, exclude={"action"})621 622                is_async = _env.step_async.__func__ is not Environment.step_async623 624                if is_async:625                    sig = inspect.signature(_env.step_async)626                else:627                    sig = inspect.signature(_env.step)628                valid_kwargs = self._get_valid_kwargs(629                    sig, kwargs, skip_params={"action"}630                )631 632                if is_async:633                    observation = await _env.step_async(action, **valid_kwargs)634                else:635                    observation = await self._run_sync_in_thread_pool(636                        _env.step, action, **valid_kwargs637                    )638 639                return StepResponse(**serialize_observation(observation))640            finally:641                _env.close()642 643        # Helper function to handle MCP endpoint644        async def mcp_handler(645            request: JsonRpcRequest,646            session_env: Optional[Environment] = None,647            session_id: Optional[str] = None,648        ) -> JsonRpcResponse:649            """650            Handle MCP JSON-RPC requests.651 652            Supports tools/list and tools/call methods in JSON-RPC 2.0 format,653            plus OpenEnv session lifecycle methods for HTTP MCP:654            - openenv/session/create655            - openenv/session/close656            """657            method = request.method658            request_id = request.id659            params = request.params660            if not isinstance(params, dict):661                return JsonRpcResponse.error_response(662                    JsonRpcErrorCode.INVALID_PARAMS,663                    "Params must be an object",664                    request_id=request_id,665                )666 667            # OpenEnv extension methods for explicit MCP session management.668            # This enables persistent MCP lifecycles over HTTP /mcp, matching WebSocket semantics.669            if method == "openenv/session/create":670                if session_env is not None and session_id is not None:671                    return JsonRpcResponse.success(672                        result={"session_id": session_id},673                        request_id=request_id,674                    )675                try:676                    created_session_id, _ = await self._create_session()677                except SessionCapacityError as e:678                    return JsonRpcResponse.error_response(679                        JsonRpcErrorCode.SERVER_ERROR,680                        str(e),681                        request_id=request_id,682                        data={683                            "active_sessions": e.active_sessions,684                            "max_sessions": e.max_sessions,685                        },686                    )687                except EnvironmentFactoryError as e:688                    return JsonRpcResponse.error_response(689                        JsonRpcErrorCode.SERVER_ERROR,690                        str(e),691                        request_id=request_id,692                        data={"factory_name": e.factory_name},693                    )694                return JsonRpcResponse.success(695                    result={"session_id": created_session_id},696                    request_id=request_id,697                )698 699            if method == "openenv/session/close":700                target_session_id = params.get("session_id")701                if not target_session_id:702                    return JsonRpcResponse.error_response(703                        JsonRpcErrorCode.INVALID_PARAMS,704                        "Invalid params - 'session_id' is required",705                        request_id=request_id,706                    )707 708                if session_id is not None and target_session_id == session_id:709                    return JsonRpcResponse.error_response(710                        JsonRpcErrorCode.INVALID_REQUEST,711                        "Cannot close active WebSocket-managed session via MCP method",712                        request_id=request_id,713                    )714 715                async with self._session_lock:716                    env = self._sessions.pop(target_session_id, _MISSING)717                    if env is not _MISSING:718                        executor = self._session_executors.pop(target_session_id, None)719                        stack = self._session_stacks.pop(target_session_id, None)720                        self._session_info.pop(target_session_id, None)721                    else:722                        executor = None723                        stack = None724 725                if env is _MISSING:726                    return JsonRpcResponse.error_response(727                        JsonRpcErrorCode.INVALID_PARAMS,728                        f"Unknown session_id: {target_session_id}",729                        request_id=request_id,730                    )731 732                if env is None:733                    # Session slot reserved but env factory still running;734                    # re-insert the placeholder AND the executor so735                    # _create_session can finish and the executor remains736                    # tracked for eventual shutdown.737                    async with self._session_lock:738                        self._sessions[target_session_id] = None739                        if executor is not None:740                            self._session_executors[target_session_id] = executor741                    return JsonRpcResponse.error_response(742                        JsonRpcErrorCode.INVALID_REQUEST,743                        f"Session {target_session_id} is still initializing; retry shortly",744                        request_id=request_id,745                    )746 747                # env/executor/stack cleanup outside the lock748                await self._cleanup_session_resources(env, executor, stack)749                return JsonRpcResponse.success(750                    result={"session_id": target_session_id, "closed": True},751                    request_id=request_id,752                )753 754            requested_session_id = params.get("session_id")755            managed_session_id = session_id756 757            # Use provided session environment or create temporary one758            if session_env is not None:759                _env = session_env760                should_close = False761            elif requested_session_id:762                async with self._session_lock:763                    _env = self._sessions.get(requested_session_id, _MISSING)764 765                if _env is _MISSING:766                    return JsonRpcResponse.error_response(767                        JsonRpcErrorCode.INVALID_PARAMS,768                        f"Unknown session_id: {requested_session_id}",769                        request_id=request_id,770                    )771 772                if _env is None:773                    return JsonRpcResponse.error_response(774                        JsonRpcErrorCode.INVALID_REQUEST,775                        f"Session {requested_session_id} is still initializing; retry shortly",776                        request_id=request_id,777                    )778 779                should_close = False780                managed_session_id = requested_session_id781            else:782                _env = self._env_factory()783                should_close = True784            try:785                mcp_client = getattr(_env, "mcp_client", None)786                mcp_server = getattr(_env, "mcp_server", None)787                mcp_session_factory = getattr(_env, "mcp_session", None)788 789                if method == McpMethod.TOOLS_LIST:790                    # Check if environment is MCP-enabled791                    if mcp_client is None and mcp_server is None:792                        return JsonRpcResponse.error_response(793                            JsonRpcErrorCode.INTERNAL_ERROR,794                            "Environment does not support MCP",795                            request_id=request_id,796                        )797 798                    if mcp_client:799                        if managed_session_id and mcp_client.is_connected():800                            # Session-managed with live transport — call801                            # directly, no redundant re-entry.802                            tools = await mcp_client.list_tools()803                        elif callable(mcp_session_factory):804                            # Stateless request, or session-managed but the805                            # background transport was lost: (re-)open.806                            mcp_session_cm = cast(807                                AsyncContextManager[Any], mcp_session_factory()808                            )809                            async with mcp_session_cm:810                                tools = await mcp_client.list_tools()811                        else:812                            async with mcp_client:813                                tools = await mcp_client.list_tools()814 815                        return JsonRpcResponse.success(816                            result={817                                "tools": [818                                    t.model_dump()819                                    if hasattr(t, "model_dump")820                                    else dict(t)821                                    for t in tools822                                ]823                            },824                            request_id=request_id,825                        )826 827                    if mcp_server:828                        tools = []829                        for _tool_name, tool in get_server_tools(mcp_server).items():830                            tools.append(831                                {832                                    "name": tool.name,833                                    "description": tool.description or "",834                                    "inputSchema": tool.parameters or {},835                                }836                            )837                        return JsonRpcResponse.success(838                            result={"tools": tools},839                            request_id=request_id,840                        )841 842                    return JsonRpcResponse.error_response(843                        JsonRpcErrorCode.INTERNAL_ERROR,844                        "MCP server not available",845                        request_id=request_id,846                    )847 848                elif method == McpMethod.TOOLS_CALL:849                    tool_name = params.get("name")850                    arguments = params.get("arguments", {})851 852                    if mcp_client is None and mcp_server is None:853                        return JsonRpcResponse.error_response(854                            JsonRpcErrorCode.INTERNAL_ERROR,855                            "Environment does not support MCP",856                            request_id=request_id,857                        )858 859                    if not tool_name:860                        return JsonRpcResponse.error_response(861                            JsonRpcErrorCode.INVALID_PARAMS,862                            "Missing 'name' in params",863                            request_id=request_id,864                        )865 866                    if mcp_client:867                        if managed_session_id and mcp_client.is_connected():868                            # Session-managed with live transport.869                            result = await mcp_client.call_tool(870                                name=tool_name, arguments=arguments871                            )872                        elif callable(mcp_session_factory):873                            # Stateless request, or session-managed but the874                            # background transport was lost: (re-)open.875                            mcp_session_cm = cast(876                                AsyncContextManager[Any], mcp_session_factory()877                            )878                            async with mcp_session_cm:879                                result = await mcp_client.call_tool(880                                    name=tool_name, arguments=arguments881                                )882                        else:883                            async with mcp_client:884                                result = await mcp_client.call_tool(885                                    name=tool_name, arguments=arguments886                                )887                    elif mcp_server:888                        server_tools = get_server_tools(mcp_server)889                        if tool_name in server_tools:890                            tool = server_tools[tool_name]891                            if inspect.iscoroutinefunction(tool.fn):892                                result = await tool.fn(**arguments)893                            else:894                                result = tool.fn(**arguments)895                        else:896                            return JsonRpcResponse.error_response(897                                JsonRpcErrorCode.INVALID_PARAMS,898                                f"Tool not found: {tool_name}",899                                request_id=request_id,900                            )901                    else:902                        return JsonRpcResponse.error_response(903                            JsonRpcErrorCode.INTERNAL_ERROR,904                            "MCP server not available",905                            request_id=request_id,906                        )907 908                    # Ensure result is JSON serializable909                    serializable_result = _make_json_serializable(result)910 911                    return JsonRpcResponse.success(912                        result=serializable_result,913                        request_id=request_id,914                    )915 916                else:917                    return JsonRpcResponse.error_response(918                        JsonRpcErrorCode.METHOD_NOT_FOUND,919                        f"Method not found: {method}",920                        request_id=request_id,921                    )922 923            except Exception as e:924                return JsonRpcResponse.error_response(925                    JsonRpcErrorCode.INTERNAL_ERROR,926                    str(e),927                    request_id=request_id,928                )929            finally:930                if managed_session_id:931                    self._update_session_activity(932                        managed_session_id,933                        increment_step=(method == McpMethod.TOOLS_CALL),934                    )935                if should_close:936                    _env.close()937 938        # Register MCP WebSocket endpoint (available in both production and simulation modes)939        @app.websocket("/mcp")940        async def mcp_websocket_endpoint(websocket: WebSocket):941            """942            WebSocket endpoint for MCP JSON-RPC requests.943 944            Each WebSocket connection gets its own environment instance for MCP operations.945 946            Message Protocol:947            - Client sends: JSON-RPC 2.0 request (tools/list, tools/call)948            - Server responds: JSON-RPC 2.0 response (result or error)949            """950            await websocket.accept()951 952            session_id = None953            session_env = None954 955            try:956                # Create session with dedicated environment957                session_id, session_env = await self._create_session()958                if session_env is None:959                    raise RuntimeError(960                        "Session environment not initialized for MCP websocket"961                    )962 963                # If environment has an mcp_session context manager, hold it open964                # for the lifetime of the websocket connection965 966                async with AsyncExitStack() as stack:967                    mcp_session_factory = getattr(session_env, "mcp_session", None)968                    if callable(mcp_session_factory):969                        mcp_session_cm = cast(970                            AsyncContextManager[Any], mcp_session_factory()971                        )972                        await stack.enter_async_context(mcp_session_cm)973 974                    while True:975                        # Receive message from client976                        raw_message = await websocket.receive_text()977 978                        try:979                            jsonrpc_dict = json.loads(raw_message)980                            jsonrpc_request = JsonRpcRequest(**jsonrpc_dict)981                        except json.JSONDecodeError as e:982                            error_resp = JsonRpcResponse.error_response(983                                JsonRpcErrorCode.PARSE_ERROR,984                                f"Parse error: {e}",985                            )986                            await websocket.send_text(error_resp.model_dump_json())987                            continue988                        except ValidationError as e:989                            error_resp = JsonRpcResponse.error_response(990                                JsonRpcErrorCode.INVALID_REQUEST,991                                f"Invalid request: {e}",992                            )993                            await websocket.send_text(error_resp.model_dump_json())994                            continue995 996                        try:997                            # Call mcp_handler with session environment998                            response = await mcp_handler(999                                jsonrpc_request,1000                                session_env=session_env,1001                                session_id=session_id,1002                            )1003                            await websocket.send_text(response.model_dump_json())1004                        except Exception as e:1005                            error_resp = JsonRpcResponse.error_response(1006                                JsonRpcErrorCode.INTERNAL_ERROR,1007                                str(e),1008                                request_id=jsonrpc_request.id,1009                            )1010                            await websocket.send_text(error_resp.model_dump_json())1011 1012            except WebSocketDisconnect:1013                pass1014            except SessionCapacityError as e:1015                error_resp = JsonRpcResponse.error_response(1016                    JsonRpcErrorCode.SERVER_ERROR,1017                    str(e),1018                    data={1019                        "active_sessions": e.active_sessions,1020                        "max_sessions": e.max_sessions,1021                    },1022                )1023                await websocket.send_text(error_resp.model_dump_json())1024            except EnvironmentFactoryError as e:1025                error_resp = JsonRpcResponse.error_response(1026                    JsonRpcErrorCode.SERVER_ERROR,1027                    str(e),1028                    data={"factory_name": e.factory_name},1029                )1030                await websocket.send_text(error_resp.model_dump_json())1031            except Exception as e:1032                error_resp = JsonRpcResponse.error_response(1033                    JsonRpcErrorCode.SERVER_ERROR,1034                    str(e),1035                )1036                await websocket.send_text(error_resp.model_dump_json())1037            finally:1038                if session_id:1039                    await self._destroy_session(session_id)1040                try:1041                    await websocket.close()1042                except RuntimeError:1043                    pass1044 1045        # Register simulation control routes only in simulation mode1046        if mode == ServerMode.SIMULATION:1047 1048            @app.post(1049                "/reset",1050                response_model=ResetResponse,1051                tags=["Environment Control"],1052                summary="Reset the environment",1053                description="""1054Reset the environment to its initial state and return the first observation.1055 1056You can optionally provide a seed for reproducibility and an episode_id for tracking.1057                """,1058                responses={1059                    200: {1060                        "description": "Environment reset successfully",1061                        "content": {1062                            "application/json": {1063                                "example": {1064                                    "observation": {"status": "ready", "data": {}},1065                                    "reward": None,1066                                    "done": False,1067                                }1068                            }1069                        },1070                    }1071                },1072            )1073            async def reset(1074                request: ResetRequest = Body(default_factory=ResetRequest),1075            ) -> ResetResponse:1076                return await reset_handler(request)1077 1078            @app.post(1079                "/step",1080                response_model=StepResponse,1081                tags=["Environment Control"],1082                summary="Execute an action in the environment",1083                description="""1084Execute an action in the environment and receive the resulting observation.1085 1086The action must conform to the environment's action schema, which can be1087retrieved from the `/schema` endpoint. If the action is invalid,1088the endpoint will return HTTP 422 with detailed validation errors.1089 1090The response includes:1091- **observation**: The environment's response to the action1092- **reward**: Optional reward signal (float or None)1093- **done**: Boolean indicating if the episode has terminated1094                """,1095                responses={1096                    200: {1097                        "description": "Action executed successfully",1098                        "content": {1099                            "application/json": {1100                                "example": {1101                                    "observation": {"status": "success", "data": {}},1102                                    "reward": 1.0,1103                                    "done": False,1104                                }1105                            }1106                        },1107                    },1108                    422: {1109                        "description": "Validation error - invalid action format or values",1110                        "content": {1111                            "application/json": {1112                                "example": {1113                                    "detail": [1114                                        {1115                                            "type": "string_too_short",1116                                            "loc": ["body", "action", "message"],1117                                            "msg": "String should have at least 1 character",1118                                            "input": "",1119                                        }1120                                    ]1121                                }1122                            }1123                        },1124                    },1125                    500: {1126                        "description": "Internal server error during action execution"1127                    },1128                },1129            )1130            async def step(request: StepRequest) -> StepResponse:1131                return await step_handler(request)1132 1133        def get_state_handler() -> State:1134            _env = self._env_factory()1135            try:1136                return _env.state1137            finally:1138                _env.close()1139 1140        def get_metadata_handler() -> EnvironmentMetadata:1141            _env = self._env_factory()1142            try:1143                return _env.get_metadata()1144            finally:1145                _env.close()1146 1147        # Build list of GET endpoints based on mode1148        get_endpoints = [1149            GetEndpointConfig(1150                path="/metadata",1151                handler=get_metadata_handler,1152                response_model=EnvironmentMetadata,1153                tag="Environment Info",1154                summary="Get environment metadata",1155                description="""1156Get metadata about this environment.1157 1158Returns information about the environment including name, description,1159version, author, and documentation links.1160                """,1161            ),1162            GetEndpointConfig(1163                path="/health",1164                handler=lambda: HealthResponse(status=HealthStatus.HEALTHY),1165                response_model=HealthResponse,1166                tag="Health",1167                summary="Health check",1168                description="Check if the environment server is running and healthy.",1169            ),1170        ]1171 1172        # Only register /state endpoint in simulation mode1173        if mode == ServerMode.SIMULATION:1174            get_endpoints.insert(1175                0,1176                GetEndpointConfig(1177                    path="/state",1178                    handler=get_state_handler,1179                    response_model=State,1180                    tag="State Management",1181                    summary="Get current environment state",1182                    description="""1183Retrieve the current internal state of the environment.1184 1185The structure of the state object is defined by the environment's State model.1186                    """,1187                ),1188            )1189 1190        register_get_endpoints(app, get_endpoints)1191 1192        # Register combined schema endpoint1193        @app.get(1194            "/schema",1195            response_model=SchemaResponse,1196            tags=["Schema"],1197            summary="Get all JSON schemas",1198            description="""1199Get JSON schemas for actions, observations, and state in a single response.1200 

Showing the first 1,200 of 1647 lines. Download the file for the rest.