burtenshaw HF Staff commited on
Commit
f818586
·
verified ·
1 Parent(s): b74674a

Upload folder using huggingface_hub

Browse files
Dockerfile CHANGED
@@ -74,6 +74,7 @@ ENV PATH="/app/.venv/bin:$PATH"
74
 
75
  # Set PYTHONPATH so imports work correctly
76
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
 
77
 
78
  # Health check using Python (more portable than curl/wget)
79
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
@@ -82,5 +83,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
82
  # Run the FastAPI server
83
  # The module path is constructed to work with the /app/env structure
84
  CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
85
-
86
- ENV ENABLE_WEB_INTERFACE=true
 
74
 
75
  # Set PYTHONPATH so imports work correctly
76
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
77
+ ENV ENABLE_WEB_INTERFACE=true
78
 
79
  # Health check using Python (more portable than curl/wget)
80
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
 
83
  # Run the FastAPI server
84
  # The module path is constructed to work with the /app/env structure
85
  CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
 
 
README.md CHANGED
@@ -8,7 +8,7 @@ pinned: false
8
  app_port: 8000
9
  base_path: /web
10
  tags:
11
- - openenv-0.2.2
12
  - openenv
13
  ---
14
 
@@ -17,7 +17,7 @@ tags:
17
  This Space is built from OpenEnv environment `repl_env`.
18
 
19
  - Space URL: `https://huggingface.co/spaces/openenv/repl`
20
- - OpenEnv pinned ref: `0.2.2`
21
  - Hub tag: `openenv`
22
 
23
  ### Connecting from Code
 
8
  app_port: 8000
9
  base_path: /web
10
  tags:
11
+ - openenv-0.2.3
12
  - openenv
13
  ---
14
 
 
17
  This Space is built from OpenEnv environment `repl_env`.
18
 
19
  - Space URL: `https://huggingface.co/spaces/openenv/repl`
20
+ - OpenEnv pinned ref: `0.2.3`
21
  - Hub tag: `openenv`
22
 
23
  ### Connecting from Code
envs/repl_env/pyproject.toml CHANGED
@@ -15,7 +15,7 @@ description = "Recursive Language Model REPL Environment for OpenEnv"
15
  requires-python = ">=3.10"
16
  dependencies = [
17
  # Core OpenEnv dependencies (required for server functionality)
18
- "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.2",
19
  "fastapi>=0.115.0",
20
  "pydantic>=2.0.0",
21
  "uvicorn>=0.24.0",
 
15
  requires-python = ">=3.10"
16
  dependencies = [
17
  # Core OpenEnv dependencies (required for server functionality)
18
+ "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.3",
19
  "fastapi>=0.115.0",
20
  "pydantic>=2.0.0",
21
  "uvicorn>=0.24.0",
envs/repl_env/server/Dockerfile CHANGED
@@ -70,6 +70,7 @@ ENV PATH="/app/.venv/bin:$PATH"
70
 
71
  # Set PYTHONPATH so imports work correctly
72
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
 
73
 
74
  # Health check using Python (more portable than curl/wget)
75
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
 
70
 
71
  # Set PYTHONPATH so imports work correctly
72
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+ ENV ENABLE_WEB_INTERFACE=true
74
 
75
  # Health check using Python (more portable than curl/wget)
76
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
envs/repl_env/server/__init__.py CHANGED
@@ -10,25 +10,6 @@ REPL Environment Server Components.
10
  This module contains the server-side implementation of the REPL environment.
11
  """
12
 
13
- import sys
14
- from pathlib import Path
15
-
16
-
17
- def _prefer_bundled_openenv_src() -> None:
18
- """Ensure bundled src/openenv wins over installed openenv-core wheels."""
19
- for parent in Path(__file__).resolve().parents:
20
- src_dir = parent / "src"
21
- if not (src_dir / "openenv").is_dir():
22
- continue
23
- src_path = str(src_dir)
24
- if src_path in sys.path:
25
- sys.path.remove(src_path)
26
- sys.path.insert(0, src_path)
27
- return
28
-
29
-
30
- _prefer_bundled_openenv_src()
31
-
32
  from .python_executor import PythonExecutor
33
  from .repl_environment import REPLEnvironment
34
 
 
10
  This module contains the server-side implementation of the REPL environment.
11
  """
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  from .python_executor import PythonExecutor
14
  from .repl_environment import REPLEnvironment
15
 
envs/repl_env/server/app.py CHANGED
@@ -38,24 +38,6 @@ Environment Variables:
38
  import inspect
39
  import logging
40
  import os
41
- import sys
42
- from pathlib import Path
43
-
44
-
45
- def _prefer_bundled_openenv_src() -> None:
46
- """Ensure the bundled repo src/ tree wins over installed openenv-core wheels."""
47
- for parent in Path(__file__).resolve().parents:
48
- src_dir = parent / "src"
49
- if not (src_dir / "openenv").is_dir():
50
- continue
51
- src_path = str(src_dir)
52
- if src_path in sys.path:
53
- sys.path.remove(src_path)
54
- sys.path.insert(0, src_path)
55
- return
56
-
57
-
58
- _prefer_bundled_openenv_src()
59
 
60
  try:
61
  from openenv.core.env_server.http_server import create_app
 
38
  import inspect
39
  import logging
40
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  try:
43
  from openenv.core.env_server.http_server import create_app
pyproject.toml CHANGED
@@ -15,7 +15,7 @@ description = "Recursive Language Model REPL Environment for OpenEnv"
15
  requires-python = ">=3.10"
16
  dependencies = [
17
  # Core OpenEnv dependencies (required for server functionality)
18
- "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.2",
19
  "fastapi>=0.115.0",
20
  "pydantic>=2.0.0",
21
  "uvicorn>=0.24.0",
 
15
  requires-python = ">=3.10"
16
  dependencies = [
17
  # Core OpenEnv dependencies (required for server functionality)
18
+ "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git@v0.2.3",
19
  "fastapi>=0.115.0",
20
  "pydantic>=2.0.0",
21
  "uvicorn>=0.24.0",
server/Dockerfile CHANGED
@@ -70,6 +70,7 @@ ENV PATH="/app/.venv/bin:$PATH"
70
 
71
  # Set PYTHONPATH so imports work correctly
72
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
 
73
 
74
  # Health check using Python (more portable than curl/wget)
75
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
 
70
 
71
  # Set PYTHONPATH so imports work correctly
72
  ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+ ENV ENABLE_WEB_INTERFACE=true
74
 
75
  # Health check using Python (more portable than curl/wget)
76
  HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
server/__init__.py CHANGED
@@ -10,25 +10,6 @@ REPL Environment Server Components.
10
  This module contains the server-side implementation of the REPL environment.
11
  """
12
 
13
- import sys
14
- from pathlib import Path
15
-
16
-
17
- def _prefer_bundled_openenv_src() -> None:
18
- """Ensure bundled src/openenv wins over installed openenv-core wheels."""
19
- for parent in Path(__file__).resolve().parents:
20
- src_dir = parent / "src"
21
- if not (src_dir / "openenv").is_dir():
22
- continue
23
- src_path = str(src_dir)
24
- if src_path in sys.path:
25
- sys.path.remove(src_path)
26
- sys.path.insert(0, src_path)
27
- return
28
-
29
-
30
- _prefer_bundled_openenv_src()
31
-
32
  from .python_executor import PythonExecutor
33
  from .repl_environment import REPLEnvironment
34
 
 
10
  This module contains the server-side implementation of the REPL environment.
11
  """
12
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  from .python_executor import PythonExecutor
14
  from .repl_environment import REPLEnvironment
15
 
server/app.py CHANGED
@@ -38,24 +38,6 @@ Environment Variables:
38
  import inspect
39
  import logging
40
  import os
41
- import sys
42
- from pathlib import Path
43
-
44
-
45
- def _prefer_bundled_openenv_src() -> None:
46
- """Ensure the bundled repo src/ tree wins over installed openenv-core wheels."""
47
- for parent in Path(__file__).resolve().parents:
48
- src_dir = parent / "src"
49
- if not (src_dir / "openenv").is_dir():
50
- continue
51
- src_path = str(src_dir)
52
- if src_path in sys.path:
53
- sys.path.remove(src_path)
54
- sys.path.insert(0, src_path)
55
- return
56
-
57
-
58
- _prefer_bundled_openenv_src()
59
 
60
  try:
61
  from openenv.core.env_server.http_server import create_app
 
38
  import inspect
39
  import logging
40
  import os
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  try:
43
  from openenv.core.env_server.http_server import create_app
src/core/env_server/interfaces.py CHANGED
@@ -6,7 +6,9 @@
6
 
7
  import inspect
8
  from abc import ABC, abstractmethod
9
- from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypedDict, TypeVar
 
 
10
 
11
  from .types import Action, EnvironmentMetadata, Observation, State
12
 
 
6
 
7
  import inspect
8
  from abc import ABC, abstractmethod
9
+ from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypeVar
10
+
11
+ from typing_extensions import TypedDict
12
 
13
  from .types import Action, EnvironmentMetadata, Observation, State
14
 
src/core/env_server/mcp_environment.py CHANGED
@@ -420,24 +420,49 @@ class MCPEnvironment(Environment):
420
  return self._step_impl(action, timeout_s=timeout_s, **kwargs)
421
 
422
  def _handle_list_tools(self) -> ListToolsObservation:
 
 
 
 
423
  """
424
- Handle a ListToolsAction by querying the MCP server.
425
 
426
  Returns:
427
- ListToolsObservation containing all available tools with their
428
- names, descriptions, and input schemas, filtered by current mode.
429
  """
430
- try:
431
- # Get current mode
432
- current_mode = getattr(self, "_mode", None)
433
 
434
- # Start with tools from FastMCP server (mode=None tools)
435
- tools_result = run_async_safely(self._async_list_tools())
 
 
 
 
 
 
 
436
 
437
- # Build list of Tool objects
438
- tools = []
 
 
 
 
 
439
 
440
- # Add FastMCP tools that are not mode-specific
 
 
 
 
 
 
 
 
 
 
 
441
  for tool in tools_result:
442
  if tool.name not in self._mode_tool_schemas:
443
  tools.append(
@@ -449,11 +474,8 @@ class MCPEnvironment(Environment):
449
  else {},
450
  )
451
  )
452
-
453
- # Add mode-specific tools available in current mode
454
  for tool_name, mode_schemas in self._mode_tool_schemas.items():
455
  if None in mode_schemas:
456
- # Tool available in all modes
457
  schema = mode_schemas[None]
458
  tools.append(
459
  Tool(
@@ -463,7 +485,6 @@ class MCPEnvironment(Environment):
463
  )
464
  )
465
  elif current_mode in mode_schemas:
466
- # Tool available in current mode
467
  schema = mode_schemas[current_mode]
468
  tools.append(
469
  Tool(
@@ -472,65 +493,30 @@ class MCPEnvironment(Environment):
472
  input_schema=schema["input_schema"],
473
  )
474
  )
475
-
476
  return ListToolsObservation(tools=tools)
477
-
478
  except Exception as e:
479
- # Return an observation with error in metadata
480
  return ListToolsObservation(
481
  tools=[],
482
- metadata={
483
- "error": str(e),
484
- "error_type": "list_tools_failed",
485
- },
486
  )
487
 
488
- async def _async_list_tools(self) -> list:
489
- """
490
- Async helper to list tools from the MCP client.
491
-
492
- Returns:
493
- List of tool objects from the MCP server.
494
- """
495
- async with self.mcp_session() as client:
496
- return await client.list_tools()
497
-
498
- def _handle_call_tool(
499
  self,
500
  action: CallToolAction,
501
  timeout_s: Optional[float] = None,
502
  ) -> CallToolObservation:
503
- """
504
- Handle a CallToolAction by invoking the specified tool.
505
-
506
- Args:
507
- action: The CallToolAction containing tool_name and arguments.
508
- timeout_s: Timeout in seconds. Defaults to MCP_TOOL_CALL_TIMEOUT (30s).
509
-
510
- Returns:
511
- CallToolObservation with the tool's result or an error.
512
- """
513
  timeout = timeout_s if timeout_s is not None else MCP_TOOL_CALL_TIMEOUT
514
-
515
- # Check if this is a mode-specific tool
516
  tool_name = action.tool_name
517
  current_mode = getattr(self, "_mode", None)
518
 
519
  if tool_name in self._mode_tools:
520
  mode_info = self._mode_tools[tool_name]
521
-
522
- # Check if tool is available in current mode
523
- # Tool is available if:
524
- # 1. It has a None mode (available in all modes), OR
525
- # 2. It has an implementation for the current mode
526
  if None in mode_info:
527
- # Use the mode-agnostic version
528
  func = mode_info[None]
529
  elif current_mode in mode_info:
530
- # Use the mode-specific version
531
  func = mode_info[current_mode]
532
  else:
533
- # Tool not available in current mode
534
  return CallToolObservation(
535
  tool_name=tool_name,
536
  result=None,
@@ -539,16 +525,11 @@ class MCPEnvironment(Environment):
539
  message=f"Tool '{tool_name}' not available in {current_mode} mode",
540
  ),
541
  )
542
-
543
- # Call the mode-specific function directly
544
  try:
545
- # Check if function is async and await if necessary
546
  if inspect.iscoroutinefunction(func):
547
- result = run_async_safely(func(**action.arguments))
548
  else:
549
  result = func(**action.arguments)
550
-
551
- # Wrap result in CallToolResult format to match FastMCP behavior
552
  return CallToolObservation(
553
  tool_name=tool_name,
554
  result=CallToolResult(
@@ -569,22 +550,12 @@ class MCPEnvironment(Environment):
569
  ),
570
  )
571
 
572
- # Not a mode-specific tool, use FastMCP
573
  try:
574
- # Run the async call_tool with timeout
575
- # Use run_async_safely to handle both sync and async contexts
576
- result = run_async_safely(
577
- asyncio.wait_for(
578
- self._async_call_tool(action.tool_name, action.arguments),
579
- timeout=timeout,
580
- )
581
- )
582
-
583
- return CallToolObservation(
584
- tool_name=action.tool_name,
585
- result=result,
586
  )
587
-
588
  except asyncio.TimeoutError:
589
  return CallToolObservation(
590
  tool_name=action.tool_name,
@@ -594,11 +565,8 @@ class MCPEnvironment(Environment):
594
  message=f"Tool '{action.tool_name}' timed out after {timeout} seconds",
595
  ),
596
  )
597
-
598
  except Exception as e:
599
  error_message = str(e)
600
-
601
- # Determine error type based on the exception
602
  if (
603
  "not found" in error_message.lower()
604
  or "unknown tool" in error_message.lower()
@@ -611,29 +579,34 @@ class MCPEnvironment(Environment):
611
  error_type = ToolErrorType.INVALID_ARGS
612
  else:
613
  error_type = ToolErrorType.EXECUTION_ERROR
614
-
615
  return CallToolObservation(
616
  tool_name=action.tool_name,
617
  result=None,
618
- error=ToolError(
619
- error_type=error_type,
620
- message=error_message,
621
- ),
622
  )
623
 
624
- async def _async_call_tool(self, tool_name: str, arguments: dict) -> Any:
 
 
 
 
 
625
  """
626
- Async helper to call a tool on the MCP server.
627
 
628
- Args:
629
- tool_name: Name of the tool to invoke.
630
- arguments: Dictionary of arguments to pass to the tool.
631
-
632
- Returns:
633
- The result from the tool execution.
634
  """
635
- async with self.mcp_session() as client:
636
- return await client.call_tool(tool_name, arguments)
 
 
 
 
 
 
 
637
 
638
  @abstractmethod
639
  def _step_impl(
 
420
  return self._step_impl(action, timeout_s=timeout_s, **kwargs)
421
 
422
  def _handle_list_tools(self) -> ListToolsObservation:
423
+ """Sync wrapper — delegates to the canonical async implementation."""
424
+ return run_async_safely(self._async_handle_list_tools())
425
+
426
+ async def _async_list_tools(self) -> list:
427
  """
428
+ Async helper to list tools from the MCP client.
429
 
430
  Returns:
431
+ List of tool objects from the MCP server.
 
432
  """
433
+ async with self.mcp_session() as client:
434
+ return await client.list_tools()
 
435
 
436
+ def _handle_call_tool(
437
+ self,
438
+ action: CallToolAction,
439
+ timeout_s: Optional[float] = None,
440
+ ) -> CallToolObservation:
441
+ """Sync wrapper — delegates to the canonical async implementation."""
442
+ return run_async_safely(
443
+ self._async_handle_call_tool(action, timeout_s=timeout_s)
444
+ )
445
 
446
+ async def _async_call_tool(self, tool_name: str, arguments: dict) -> Any:
447
+ """
448
+ Async helper to call a tool on the MCP server.
449
+
450
+ Args:
451
+ tool_name: Name of the tool to invoke.
452
+ arguments: Dictionary of arguments to pass to the tool.
453
 
454
+ Returns:
455
+ The result from the tool execution.
456
+ """
457
+ async with self.mcp_session() as client:
458
+ return await client.call_tool(tool_name, arguments)
459
+
460
+ async def _async_handle_list_tools(self) -> ListToolsObservation:
461
+ """Async version of _handle_list_tools — avoids run_async_safely."""
462
+ try:
463
+ current_mode = getattr(self, "_mode", None)
464
+ tools_result = await self._async_list_tools()
465
+ tools = []
466
  for tool in tools_result:
467
  if tool.name not in self._mode_tool_schemas:
468
  tools.append(
 
474
  else {},
475
  )
476
  )
 
 
477
  for tool_name, mode_schemas in self._mode_tool_schemas.items():
478
  if None in mode_schemas:
 
479
  schema = mode_schemas[None]
480
  tools.append(
481
  Tool(
 
485
  )
486
  )
487
  elif current_mode in mode_schemas:
 
488
  schema = mode_schemas[current_mode]
489
  tools.append(
490
  Tool(
 
493
  input_schema=schema["input_schema"],
494
  )
495
  )
 
496
  return ListToolsObservation(tools=tools)
 
497
  except Exception as e:
 
498
  return ListToolsObservation(
499
  tools=[],
500
+ metadata={"error": str(e), "error_type": "list_tools_failed"},
 
 
 
501
  )
502
 
503
+ async def _async_handle_call_tool(
 
 
 
 
 
 
 
 
 
 
504
  self,
505
  action: CallToolAction,
506
  timeout_s: Optional[float] = None,
507
  ) -> CallToolObservation:
508
+ """Async version of _handle_call_tool — avoids run_async_safely."""
 
 
 
 
 
 
 
 
 
509
  timeout = timeout_s if timeout_s is not None else MCP_TOOL_CALL_TIMEOUT
 
 
510
  tool_name = action.tool_name
511
  current_mode = getattr(self, "_mode", None)
512
 
513
  if tool_name in self._mode_tools:
514
  mode_info = self._mode_tools[tool_name]
 
 
 
 
 
515
  if None in mode_info:
 
516
  func = mode_info[None]
517
  elif current_mode in mode_info:
 
518
  func = mode_info[current_mode]
519
  else:
 
520
  return CallToolObservation(
521
  tool_name=tool_name,
522
  result=None,
 
525
  message=f"Tool '{tool_name}' not available in {current_mode} mode",
526
  ),
527
  )
 
 
528
  try:
 
529
  if inspect.iscoroutinefunction(func):
530
+ result = await func(**action.arguments)
531
  else:
532
  result = func(**action.arguments)
 
 
533
  return CallToolObservation(
534
  tool_name=tool_name,
535
  result=CallToolResult(
 
550
  ),
551
  )
552
 
 
553
  try:
554
+ result = await asyncio.wait_for(
555
+ self._async_call_tool(action.tool_name, action.arguments),
556
+ timeout=timeout,
 
 
 
 
 
 
 
 
 
557
  )
558
+ return CallToolObservation(tool_name=action.tool_name, result=result)
559
  except asyncio.TimeoutError:
560
  return CallToolObservation(
561
  tool_name=action.tool_name,
 
565
  message=f"Tool '{action.tool_name}' timed out after {timeout} seconds",
566
  ),
567
  )
 
568
  except Exception as e:
569
  error_message = str(e)
 
 
570
  if (
571
  "not found" in error_message.lower()
572
  or "unknown tool" in error_message.lower()
 
579
  error_type = ToolErrorType.INVALID_ARGS
580
  else:
581
  error_type = ToolErrorType.EXECUTION_ERROR
 
582
  return CallToolObservation(
583
  tool_name=action.tool_name,
584
  result=None,
585
+ error=ToolError(error_type=error_type, message=error_message),
 
 
 
586
  )
587
 
588
+ async def step_async(
589
+ self,
590
+ action: Action,
591
+ timeout_s: Optional[float] = None,
592
+ **kwargs: Any,
593
+ ) -> Observation:
594
  """
595
+ Async step that routes MCP actions without going through run_async_safely.
596
 
597
+ The WebSocket handler calls this directly on the outer event loop, where
598
+ the MCP session is already open, avoiding the thread/event-loop deadlock
599
+ that occurs when the sync step() path is used via run_in_executor.
 
 
 
600
  """
601
+ if isinstance(action, ListToolsAction):
602
+ return await self._async_handle_list_tools()
603
+ elif isinstance(action, CallToolAction):
604
+ return await self._async_handle_call_tool(action, timeout_s=timeout_s)
605
+ else:
606
+ loop = asyncio.get_event_loop()
607
+ return await loop.run_in_executor(
608
+ None, lambda: self._step_impl(action, timeout_s=timeout_s, **kwargs)
609
+ )
610
 
611
  @abstractmethod
612
  def _step_impl(
src/core/env_server/web_interface.py CHANGED
@@ -22,7 +22,7 @@ from datetime import datetime
22
  from typing import Any, Callable, Dict, List, Optional, Type
23
 
24
  import gradio as gr
25
- from fastapi import Body, FastAPI, HTTPException, WebSocket, WebSocketDisconnect, status
26
  from fastapi.responses import RedirectResponse
27
  from pydantic import BaseModel, ConfigDict, Field
28
 
@@ -356,7 +356,9 @@ class WebInterfaceManager:
356
  else:
357
  # Run sync reset in thread pool to avoid blocking event loop
358
  # and to support environments using sync libraries (e.g., Playwright)
359
- observation = await self._run_sync_in_thread_pool(self.env.reset, **valid_kwargs)
 
 
360
  state: State = self.env.state
361
 
362
  # Serialize observation once using shared utility
 
22
  from typing import Any, Callable, Dict, List, Optional, Type
23
 
24
  import gradio as gr
25
+ from fastapi import Body, FastAPI, HTTPException, status, WebSocket, WebSocketDisconnect
26
  from fastapi.responses import RedirectResponse
27
  from pydantic import BaseModel, ConfigDict, Field
28
 
 
356
  else:
357
  # Run sync reset in thread pool to avoid blocking event loop
358
  # and to support environments using sync libraries (e.g., Playwright)
359
+ observation = await self._run_sync_in_thread_pool(
360
+ self.env.reset, **valid_kwargs
361
+ )
362
  state: State = self.env.state
363
 
364
  # Serialize observation once using shared utility
src/core/openenv/__init__.py CHANGED
@@ -14,10 +14,18 @@ __all__ = [
14
  "SyncEnvClient",
15
  ]
16
 
17
- try:
18
- __version__ = metadata.version("openenv") # type: ignore[arg-type]
19
- except metadata.PackageNotFoundError: # pragma: no cover - local dev
20
- __version__ = "0.0.0"
 
 
 
 
 
 
 
 
21
 
22
 
23
  _LAZY_MODULES = {
 
14
  "SyncEnvClient",
15
  ]
16
 
17
+
18
+ def _load_package_version() -> str:
19
+ """Resolve the installed distribution version for the OpenEnv package."""
20
+ for distribution_name in ("openenv-core", "openenv"):
21
+ try:
22
+ return metadata.version(distribution_name)
23
+ except metadata.PackageNotFoundError:
24
+ continue
25
+ return "0.0.0"
26
+
27
+
28
+ __version__ = _load_package_version()
29
 
30
 
31
  _LAZY_MODULES = {
src/core/openenv/cli/templates/openenv_env/pyproject.toml CHANGED
@@ -17,7 +17,7 @@ dependencies = [
17
  # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
  # install from github
19
  # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
- "openenv-core[core]>=0.2.1",
21
  # Environment-specific dependencies
22
  # Add all dependencies needed for your environment here
23
  # Examples:
 
17
  # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
  # install from github
19
  # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
+ "openenv-core[core]>=0.2.2",
21
  # Environment-specific dependencies
22
  # Add all dependencies needed for your environment here
23
  # Examples:
src/core/openenv/core/env_server/interfaces.py CHANGED
@@ -6,7 +6,9 @@
6
 
7
  import inspect
8
  from abc import ABC, abstractmethod
9
- from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypedDict, TypeVar
 
 
10
 
11
  from .types import Action, EnvironmentMetadata, Observation, State
12
 
 
6
 
7
  import inspect
8
  from abc import ABC, abstractmethod
9
+ from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypeVar
10
+
11
+ from typing_extensions import TypedDict
12
 
13
  from .types import Action, EnvironmentMetadata, Observation, State
14
 
src/core/openenv/core/env_server/mcp_environment.py CHANGED
@@ -420,24 +420,49 @@ class MCPEnvironment(Environment):
420
  return self._step_impl(action, timeout_s=timeout_s, **kwargs)
421
 
422
  def _handle_list_tools(self) -> ListToolsObservation:
 
 
 
 
423
  """
424
- Handle a ListToolsAction by querying the MCP server.
425
 
426
  Returns:
427
- ListToolsObservation containing all available tools with their
428
- names, descriptions, and input schemas, filtered by current mode.
429
  """
430
- try:
431
- # Get current mode
432
- current_mode = getattr(self, "_mode", None)
433
 
434
- # Start with tools from FastMCP server (mode=None tools)
435
- tools_result = run_async_safely(self._async_list_tools())
 
 
 
 
 
 
 
436
 
437
- # Build list of Tool objects
438
- tools = []
 
 
 
 
 
439
 
440
- # Add FastMCP tools that are not mode-specific
 
 
 
 
 
 
 
 
 
 
 
441
  for tool in tools_result:
442
  if tool.name not in self._mode_tool_schemas:
443
  tools.append(
@@ -449,11 +474,8 @@ class MCPEnvironment(Environment):
449
  else {},
450
  )
451
  )
452
-
453
- # Add mode-specific tools available in current mode
454
  for tool_name, mode_schemas in self._mode_tool_schemas.items():
455
  if None in mode_schemas:
456
- # Tool available in all modes
457
  schema = mode_schemas[None]
458
  tools.append(
459
  Tool(
@@ -463,7 +485,6 @@ class MCPEnvironment(Environment):
463
  )
464
  )
465
  elif current_mode in mode_schemas:
466
- # Tool available in current mode
467
  schema = mode_schemas[current_mode]
468
  tools.append(
469
  Tool(
@@ -472,65 +493,30 @@ class MCPEnvironment(Environment):
472
  input_schema=schema["input_schema"],
473
  )
474
  )
475
-
476
  return ListToolsObservation(tools=tools)
477
-
478
  except Exception as e:
479
- # Return an observation with error in metadata
480
  return ListToolsObservation(
481
  tools=[],
482
- metadata={
483
- "error": str(e),
484
- "error_type": "list_tools_failed",
485
- },
486
  )
487
 
488
- async def _async_list_tools(self) -> list:
489
- """
490
- Async helper to list tools from the MCP client.
491
-
492
- Returns:
493
- List of tool objects from the MCP server.
494
- """
495
- async with self.mcp_session() as client:
496
- return await client.list_tools()
497
-
498
- def _handle_call_tool(
499
  self,
500
  action: CallToolAction,
501
  timeout_s: Optional[float] = None,
502
  ) -> CallToolObservation:
503
- """
504
- Handle a CallToolAction by invoking the specified tool.
505
-
506
- Args:
507
- action: The CallToolAction containing tool_name and arguments.
508
- timeout_s: Timeout in seconds. Defaults to MCP_TOOL_CALL_TIMEOUT (30s).
509
-
510
- Returns:
511
- CallToolObservation with the tool's result or an error.
512
- """
513
  timeout = timeout_s if timeout_s is not None else MCP_TOOL_CALL_TIMEOUT
514
-
515
- # Check if this is a mode-specific tool
516
  tool_name = action.tool_name
517
  current_mode = getattr(self, "_mode", None)
518
 
519
  if tool_name in self._mode_tools:
520
  mode_info = self._mode_tools[tool_name]
521
-
522
- # Check if tool is available in current mode
523
- # Tool is available if:
524
- # 1. It has a None mode (available in all modes), OR
525
- # 2. It has an implementation for the current mode
526
  if None in mode_info:
527
- # Use the mode-agnostic version
528
  func = mode_info[None]
529
  elif current_mode in mode_info:
530
- # Use the mode-specific version
531
  func = mode_info[current_mode]
532
  else:
533
- # Tool not available in current mode
534
  return CallToolObservation(
535
  tool_name=tool_name,
536
  result=None,
@@ -539,16 +525,11 @@ class MCPEnvironment(Environment):
539
  message=f"Tool '{tool_name}' not available in {current_mode} mode",
540
  ),
541
  )
542
-
543
- # Call the mode-specific function directly
544
  try:
545
- # Check if function is async and await if necessary
546
  if inspect.iscoroutinefunction(func):
547
- result = run_async_safely(func(**action.arguments))
548
  else:
549
  result = func(**action.arguments)
550
-
551
- # Wrap result in CallToolResult format to match FastMCP behavior
552
  return CallToolObservation(
553
  tool_name=tool_name,
554
  result=CallToolResult(
@@ -569,22 +550,12 @@ class MCPEnvironment(Environment):
569
  ),
570
  )
571
 
572
- # Not a mode-specific tool, use FastMCP
573
  try:
574
- # Run the async call_tool with timeout
575
- # Use run_async_safely to handle both sync and async contexts
576
- result = run_async_safely(
577
- asyncio.wait_for(
578
- self._async_call_tool(action.tool_name, action.arguments),
579
- timeout=timeout,
580
- )
581
- )
582
-
583
- return CallToolObservation(
584
- tool_name=action.tool_name,
585
- result=result,
586
  )
587
-
588
  except asyncio.TimeoutError:
589
  return CallToolObservation(
590
  tool_name=action.tool_name,
@@ -594,11 +565,8 @@ class MCPEnvironment(Environment):
594
  message=f"Tool '{action.tool_name}' timed out after {timeout} seconds",
595
  ),
596
  )
597
-
598
  except Exception as e:
599
  error_message = str(e)
600
-
601
- # Determine error type based on the exception
602
  if (
603
  "not found" in error_message.lower()
604
  or "unknown tool" in error_message.lower()
@@ -611,29 +579,34 @@ class MCPEnvironment(Environment):
611
  error_type = ToolErrorType.INVALID_ARGS
612
  else:
613
  error_type = ToolErrorType.EXECUTION_ERROR
614
-
615
  return CallToolObservation(
616
  tool_name=action.tool_name,
617
  result=None,
618
- error=ToolError(
619
- error_type=error_type,
620
- message=error_message,
621
- ),
622
  )
623
 
624
- async def _async_call_tool(self, tool_name: str, arguments: dict) -> Any:
 
 
 
 
 
625
  """
626
- Async helper to call a tool on the MCP server.
627
 
628
- Args:
629
- tool_name: Name of the tool to invoke.
630
- arguments: Dictionary of arguments to pass to the tool.
631
-
632
- Returns:
633
- The result from the tool execution.
634
  """
635
- async with self.mcp_session() as client:
636
- return await client.call_tool(tool_name, arguments)
 
 
 
 
 
 
 
637
 
638
  @abstractmethod
639
  def _step_impl(
 
420
  return self._step_impl(action, timeout_s=timeout_s, **kwargs)
421
 
422
  def _handle_list_tools(self) -> ListToolsObservation:
423
+ """Sync wrapper — delegates to the canonical async implementation."""
424
+ return run_async_safely(self._async_handle_list_tools())
425
+
426
+ async def _async_list_tools(self) -> list:
427
  """
428
+ Async helper to list tools from the MCP client.
429
 
430
  Returns:
431
+ List of tool objects from the MCP server.
 
432
  """
433
+ async with self.mcp_session() as client:
434
+ return await client.list_tools()
 
435
 
436
+ def _handle_call_tool(
437
+ self,
438
+ action: CallToolAction,
439
+ timeout_s: Optional[float] = None,
440
+ ) -> CallToolObservation:
441
+ """Sync wrapper — delegates to the canonical async implementation."""
442
+ return run_async_safely(
443
+ self._async_handle_call_tool(action, timeout_s=timeout_s)
444
+ )
445
 
446
+ async def _async_call_tool(self, tool_name: str, arguments: dict) -> Any:
447
+ """
448
+ Async helper to call a tool on the MCP server.
449
+
450
+ Args:
451
+ tool_name: Name of the tool to invoke.
452
+ arguments: Dictionary of arguments to pass to the tool.
453
 
454
+ Returns:
455
+ The result from the tool execution.
456
+ """
457
+ async with self.mcp_session() as client:
458
+ return await client.call_tool(tool_name, arguments)
459
+
460
+ async def _async_handle_list_tools(self) -> ListToolsObservation:
461
+ """Async version of _handle_list_tools — avoids run_async_safely."""
462
+ try:
463
+ current_mode = getattr(self, "_mode", None)
464
+ tools_result = await self._async_list_tools()
465
+ tools = []
466
  for tool in tools_result:
467
  if tool.name not in self._mode_tool_schemas:
468
  tools.append(
 
474
  else {},
475
  )
476
  )
 
 
477
  for tool_name, mode_schemas in self._mode_tool_schemas.items():
478
  if None in mode_schemas:
 
479
  schema = mode_schemas[None]
480
  tools.append(
481
  Tool(
 
485
  )
486
  )
487
  elif current_mode in mode_schemas:
 
488
  schema = mode_schemas[current_mode]
489
  tools.append(
490
  Tool(
 
493
  input_schema=schema["input_schema"],
494
  )
495
  )
 
496
  return ListToolsObservation(tools=tools)
 
497
  except Exception as e:
 
498
  return ListToolsObservation(
499
  tools=[],
500
+ metadata={"error": str(e), "error_type": "list_tools_failed"},
 
 
 
501
  )
502
 
503
+ async def _async_handle_call_tool(
 
 
 
 
 
 
 
 
 
 
504
  self,
505
  action: CallToolAction,
506
  timeout_s: Optional[float] = None,
507
  ) -> CallToolObservation:
508
+ """Async version of _handle_call_tool — avoids run_async_safely."""
 
 
 
 
 
 
 
 
 
509
  timeout = timeout_s if timeout_s is not None else MCP_TOOL_CALL_TIMEOUT
 
 
510
  tool_name = action.tool_name
511
  current_mode = getattr(self, "_mode", None)
512
 
513
  if tool_name in self._mode_tools:
514
  mode_info = self._mode_tools[tool_name]
 
 
 
 
 
515
  if None in mode_info:
 
516
  func = mode_info[None]
517
  elif current_mode in mode_info:
 
518
  func = mode_info[current_mode]
519
  else:
 
520
  return CallToolObservation(
521
  tool_name=tool_name,
522
  result=None,
 
525
  message=f"Tool '{tool_name}' not available in {current_mode} mode",
526
  ),
527
  )
 
 
528
  try:
 
529
  if inspect.iscoroutinefunction(func):
530
+ result = await func(**action.arguments)
531
  else:
532
  result = func(**action.arguments)
 
 
533
  return CallToolObservation(
534
  tool_name=tool_name,
535
  result=CallToolResult(
 
550
  ),
551
  )
552
 
 
553
  try:
554
+ result = await asyncio.wait_for(
555
+ self._async_call_tool(action.tool_name, action.arguments),
556
+ timeout=timeout,
 
 
 
 
 
 
 
 
 
557
  )
558
+ return CallToolObservation(tool_name=action.tool_name, result=result)
559
  except asyncio.TimeoutError:
560
  return CallToolObservation(
561
  tool_name=action.tool_name,
 
565
  message=f"Tool '{action.tool_name}' timed out after {timeout} seconds",
566
  ),
567
  )
 
568
  except Exception as e:
569
  error_message = str(e)
 
 
570
  if (
571
  "not found" in error_message.lower()
572
  or "unknown tool" in error_message.lower()
 
579
  error_type = ToolErrorType.INVALID_ARGS
580
  else:
581
  error_type = ToolErrorType.EXECUTION_ERROR
 
582
  return CallToolObservation(
583
  tool_name=action.tool_name,
584
  result=None,
585
+ error=ToolError(error_type=error_type, message=error_message),
 
 
 
586
  )
587
 
588
+ async def step_async(
589
+ self,
590
+ action: Action,
591
+ timeout_s: Optional[float] = None,
592
+ **kwargs: Any,
593
+ ) -> Observation:
594
  """
595
+ Async step that routes MCP actions without going through run_async_safely.
596
 
597
+ The WebSocket handler calls this directly on the outer event loop, where
598
+ the MCP session is already open, avoiding the thread/event-loop deadlock
599
+ that occurs when the sync step() path is used via run_in_executor.
 
 
 
600
  """
601
+ if isinstance(action, ListToolsAction):
602
+ return await self._async_handle_list_tools()
603
+ elif isinstance(action, CallToolAction):
604
+ return await self._async_handle_call_tool(action, timeout_s=timeout_s)
605
+ else:
606
+ loop = asyncio.get_event_loop()
607
+ return await loop.run_in_executor(
608
+ None, lambda: self._step_impl(action, timeout_s=timeout_s, **kwargs)
609
+ )
610
 
611
  @abstractmethod
612
  def _step_impl(
src/core/openenv/core/env_server/web_interface.py CHANGED
@@ -22,7 +22,7 @@ from datetime import datetime
22
  from typing import Any, Callable, Dict, List, Optional, Type
23
 
24
  import gradio as gr
25
- from fastapi import Body, FastAPI, HTTPException, WebSocket, WebSocketDisconnect, status
26
  from fastapi.responses import RedirectResponse
27
  from pydantic import BaseModel, ConfigDict, Field
28
 
@@ -356,7 +356,9 @@ class WebInterfaceManager:
356
  else:
357
  # Run sync reset in thread pool to avoid blocking event loop
358
  # and to support environments using sync libraries (e.g., Playwright)
359
- observation = await self._run_sync_in_thread_pool(self.env.reset, **valid_kwargs)
 
 
360
  state: State = self.env.state
361
 
362
  # Serialize observation once using shared utility
 
22
  from typing import Any, Callable, Dict, List, Optional, Type
23
 
24
  import gradio as gr
25
+ from fastapi import Body, FastAPI, HTTPException, status, WebSocket, WebSocketDisconnect
26
  from fastapi.responses import RedirectResponse
27
  from pydantic import BaseModel, ConfigDict, Field
28
 
 
356
  else:
357
  # Run sync reset in thread pool to avoid blocking event loop
358
  # and to support environments using sync libraries (e.g., Playwright)
359
+ observation = await self._run_sync_in_thread_pool(
360
+ self.env.reset, **valid_kwargs
361
+ )
362
  state: State = self.env.state
363
 
364
  # Serialize observation once using shared utility
src/openenv/__init__.py CHANGED
@@ -14,10 +14,18 @@ __all__ = [
14
  "SyncEnvClient",
15
  ]
16
 
17
- try:
18
- __version__ = metadata.version("openenv") # type: ignore[arg-type]
19
- except metadata.PackageNotFoundError: # pragma: no cover - local dev
20
- __version__ = "0.0.0"
 
 
 
 
 
 
 
 
21
 
22
 
23
  _LAZY_MODULES = {
 
14
  "SyncEnvClient",
15
  ]
16
 
17
+
18
+ def _load_package_version() -> str:
19
+ """Resolve the installed distribution version for the OpenEnv package."""
20
+ for distribution_name in ("openenv-core", "openenv"):
21
+ try:
22
+ return metadata.version(distribution_name)
23
+ except metadata.PackageNotFoundError:
24
+ continue
25
+ return "0.0.0"
26
+
27
+
28
+ __version__ = _load_package_version()
29
 
30
 
31
  _LAZY_MODULES = {
src/openenv/cli/templates/openenv_env/pyproject.toml CHANGED
@@ -17,7 +17,7 @@ dependencies = [
17
  # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
  # install from github
19
  # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
- "openenv-core[core]>=0.2.1",
21
  # Environment-specific dependencies
22
  # Add all dependencies needed for your environment here
23
  # Examples:
 
17
  # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
  # install from github
19
  # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
+ "openenv-core[core]>=0.2.2",
21
  # Environment-specific dependencies
22
  # Add all dependencies needed for your environment here
23
  # Examples:
src/openenv/core/env_server/interfaces.py CHANGED
@@ -6,7 +6,9 @@
6
 
7
  import inspect
8
  from abc import ABC, abstractmethod
9
- from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypedDict, TypeVar
 
 
10
 
11
  from .types import Action, EnvironmentMetadata, Observation, State
12
 
 
6
 
7
  import inspect
8
  from abc import ABC, abstractmethod
9
+ from typing import Any, Generic, Optional, Protocol, TYPE_CHECKING, TypeVar
10
+
11
+ from typing_extensions import TypedDict
12
 
13
  from .types import Action, EnvironmentMetadata, Observation, State
14
 
src/openenv/core/env_server/mcp_environment.py CHANGED
@@ -420,24 +420,49 @@ class MCPEnvironment(Environment):
420
  return self._step_impl(action, timeout_s=timeout_s, **kwargs)
421
 
422
  def _handle_list_tools(self) -> ListToolsObservation:
 
 
 
 
423
  """
424
- Handle a ListToolsAction by querying the MCP server.
425
 
426
  Returns:
427
- ListToolsObservation containing all available tools with their
428
- names, descriptions, and input schemas, filtered by current mode.
429
  """
430
- try:
431
- # Get current mode
432
- current_mode = getattr(self, "_mode", None)
433
 
434
- # Start with tools from FastMCP server (mode=None tools)
435
- tools_result = run_async_safely(self._async_list_tools())
 
 
 
 
 
 
 
436
 
437
- # Build list of Tool objects
438
- tools = []
 
 
 
 
 
439
 
440
- # Add FastMCP tools that are not mode-specific
 
 
 
 
 
 
 
 
 
 
 
441
  for tool in tools_result:
442
  if tool.name not in self._mode_tool_schemas:
443
  tools.append(
@@ -449,11 +474,8 @@ class MCPEnvironment(Environment):
449
  else {},
450
  )
451
  )
452
-
453
- # Add mode-specific tools available in current mode
454
  for tool_name, mode_schemas in self._mode_tool_schemas.items():
455
  if None in mode_schemas:
456
- # Tool available in all modes
457
  schema = mode_schemas[None]
458
  tools.append(
459
  Tool(
@@ -463,7 +485,6 @@ class MCPEnvironment(Environment):
463
  )
464
  )
465
  elif current_mode in mode_schemas:
466
- # Tool available in current mode
467
  schema = mode_schemas[current_mode]
468
  tools.append(
469
  Tool(
@@ -472,65 +493,30 @@ class MCPEnvironment(Environment):
472
  input_schema=schema["input_schema"],
473
  )
474
  )
475
-
476
  return ListToolsObservation(tools=tools)
477
-
478
  except Exception as e:
479
- # Return an observation with error in metadata
480
  return ListToolsObservation(
481
  tools=[],
482
- metadata={
483
- "error": str(e),
484
- "error_type": "list_tools_failed",
485
- },
486
  )
487
 
488
- async def _async_list_tools(self) -> list:
489
- """
490
- Async helper to list tools from the MCP client.
491
-
492
- Returns:
493
- List of tool objects from the MCP server.
494
- """
495
- async with self.mcp_session() as client:
496
- return await client.list_tools()
497
-
498
- def _handle_call_tool(
499
  self,
500
  action: CallToolAction,
501
  timeout_s: Optional[float] = None,
502
  ) -> CallToolObservation:
503
- """
504
- Handle a CallToolAction by invoking the specified tool.
505
-
506
- Args:
507
- action: The CallToolAction containing tool_name and arguments.
508
- timeout_s: Timeout in seconds. Defaults to MCP_TOOL_CALL_TIMEOUT (30s).
509
-
510
- Returns:
511
- CallToolObservation with the tool's result or an error.
512
- """
513
  timeout = timeout_s if timeout_s is not None else MCP_TOOL_CALL_TIMEOUT
514
-
515
- # Check if this is a mode-specific tool
516
  tool_name = action.tool_name
517
  current_mode = getattr(self, "_mode", None)
518
 
519
  if tool_name in self._mode_tools:
520
  mode_info = self._mode_tools[tool_name]
521
-
522
- # Check if tool is available in current mode
523
- # Tool is available if:
524
- # 1. It has a None mode (available in all modes), OR
525
- # 2. It has an implementation for the current mode
526
  if None in mode_info:
527
- # Use the mode-agnostic version
528
  func = mode_info[None]
529
  elif current_mode in mode_info:
530
- # Use the mode-specific version
531
  func = mode_info[current_mode]
532
  else:
533
- # Tool not available in current mode
534
  return CallToolObservation(
535
  tool_name=tool_name,
536
  result=None,
@@ -539,16 +525,11 @@ class MCPEnvironment(Environment):
539
  message=f"Tool '{tool_name}' not available in {current_mode} mode",
540
  ),
541
  )
542
-
543
- # Call the mode-specific function directly
544
  try:
545
- # Check if function is async and await if necessary
546
  if inspect.iscoroutinefunction(func):
547
- result = run_async_safely(func(**action.arguments))
548
  else:
549
  result = func(**action.arguments)
550
-
551
- # Wrap result in CallToolResult format to match FastMCP behavior
552
  return CallToolObservation(
553
  tool_name=tool_name,
554
  result=CallToolResult(
@@ -569,22 +550,12 @@ class MCPEnvironment(Environment):
569
  ),
570
  )
571
 
572
- # Not a mode-specific tool, use FastMCP
573
  try:
574
- # Run the async call_tool with timeout
575
- # Use run_async_safely to handle both sync and async contexts
576
- result = run_async_safely(
577
- asyncio.wait_for(
578
- self._async_call_tool(action.tool_name, action.arguments),
579
- timeout=timeout,
580
- )
581
- )
582
-
583
- return CallToolObservation(
584
- tool_name=action.tool_name,
585
- result=result,
586
  )
587
-
588
  except asyncio.TimeoutError:
589
  return CallToolObservation(
590
  tool_name=action.tool_name,
@@ -594,11 +565,8 @@ class MCPEnvironment(Environment):
594
  message=f"Tool '{action.tool_name}' timed out after {timeout} seconds",
595
  ),
596
  )
597
-
598
  except Exception as e:
599
  error_message = str(e)
600
-
601
- # Determine error type based on the exception
602
  if (
603
  "not found" in error_message.lower()
604
  or "unknown tool" in error_message.lower()
@@ -611,29 +579,34 @@ class MCPEnvironment(Environment):
611
  error_type = ToolErrorType.INVALID_ARGS
612
  else:
613
  error_type = ToolErrorType.EXECUTION_ERROR
614
-
615
  return CallToolObservation(
616
  tool_name=action.tool_name,
617
  result=None,
618
- error=ToolError(
619
- error_type=error_type,
620
- message=error_message,
621
- ),
622
  )
623
 
624
- async def _async_call_tool(self, tool_name: str, arguments: dict) -> Any:
 
 
 
 
 
625
  """
626
- Async helper to call a tool on the MCP server.
627
 
628
- Args:
629
- tool_name: Name of the tool to invoke.
630
- arguments: Dictionary of arguments to pass to the tool.
631
-
632
- Returns:
633
- The result from the tool execution.
634
  """
635
- async with self.mcp_session() as client:
636
- return await client.call_tool(tool_name, arguments)
 
 
 
 
 
 
 
637
 
638
  @abstractmethod
639
  def _step_impl(
 
420
  return self._step_impl(action, timeout_s=timeout_s, **kwargs)
421
 
422
  def _handle_list_tools(self) -> ListToolsObservation:
423
+ """Sync wrapper — delegates to the canonical async implementation."""
424
+ return run_async_safely(self._async_handle_list_tools())
425
+
426
+ async def _async_list_tools(self) -> list:
427
  """
428
+ Async helper to list tools from the MCP client.
429
 
430
  Returns:
431
+ List of tool objects from the MCP server.
 
432
  """
433
+ async with self.mcp_session() as client:
434
+ return await client.list_tools()
 
435
 
436
+ def _handle_call_tool(
437
+ self,
438
+ action: CallToolAction,
439
+ timeout_s: Optional[float] = None,
440
+ ) -> CallToolObservation:
441
+ """Sync wrapper — delegates to the canonical async implementation."""
442
+ return run_async_safely(
443
+ self._async_handle_call_tool(action, timeout_s=timeout_s)
444
+ )
445
 
446
+ async def _async_call_tool(self, tool_name: str, arguments: dict) -> Any:
447
+ """
448
+ Async helper to call a tool on the MCP server.
449
+
450
+ Args:
451
+ tool_name: Name of the tool to invoke.
452
+ arguments: Dictionary of arguments to pass to the tool.
453
 
454
+ Returns:
455
+ The result from the tool execution.
456
+ """
457
+ async with self.mcp_session() as client:
458
+ return await client.call_tool(tool_name, arguments)
459
+
460
+ async def _async_handle_list_tools(self) -> ListToolsObservation:
461
+ """Async version of _handle_list_tools — avoids run_async_safely."""
462
+ try:
463
+ current_mode = getattr(self, "_mode", None)
464
+ tools_result = await self._async_list_tools()
465
+ tools = []
466
  for tool in tools_result:
467
  if tool.name not in self._mode_tool_schemas:
468
  tools.append(
 
474
  else {},
475
  )
476
  )
 
 
477
  for tool_name, mode_schemas in self._mode_tool_schemas.items():
478
  if None in mode_schemas:
 
479
  schema = mode_schemas[None]
480
  tools.append(
481
  Tool(
 
485
  )
486
  )
487
  elif current_mode in mode_schemas:
 
488
  schema = mode_schemas[current_mode]
489
  tools.append(
490
  Tool(
 
493
  input_schema=schema["input_schema"],
494
  )
495
  )
 
496
  return ListToolsObservation(tools=tools)
 
497
  except Exception as e:
 
498
  return ListToolsObservation(
499
  tools=[],
500
+ metadata={"error": str(e), "error_type": "list_tools_failed"},
 
 
 
501
  )
502
 
503
+ async def _async_handle_call_tool(
 
 
 
 
 
 
 
 
 
 
504
  self,
505
  action: CallToolAction,
506
  timeout_s: Optional[float] = None,
507
  ) -> CallToolObservation:
508
+ """Async version of _handle_call_tool — avoids run_async_safely."""
 
 
 
 
 
 
 
 
 
509
  timeout = timeout_s if timeout_s is not None else MCP_TOOL_CALL_TIMEOUT
 
 
510
  tool_name = action.tool_name
511
  current_mode = getattr(self, "_mode", None)
512
 
513
  if tool_name in self._mode_tools:
514
  mode_info = self._mode_tools[tool_name]
 
 
 
 
 
515
  if None in mode_info:
 
516
  func = mode_info[None]
517
  elif current_mode in mode_info:
 
518
  func = mode_info[current_mode]
519
  else:
 
520
  return CallToolObservation(
521
  tool_name=tool_name,
522
  result=None,
 
525
  message=f"Tool '{tool_name}' not available in {current_mode} mode",
526
  ),
527
  )
 
 
528
  try:
 
529
  if inspect.iscoroutinefunction(func):
530
+ result = await func(**action.arguments)
531
  else:
532
  result = func(**action.arguments)
 
 
533
  return CallToolObservation(
534
  tool_name=tool_name,
535
  result=CallToolResult(
 
550
  ),
551
  )
552
 
 
553
  try:
554
+ result = await asyncio.wait_for(
555
+ self._async_call_tool(action.tool_name, action.arguments),
556
+ timeout=timeout,
 
 
 
 
 
 
 
 
 
557
  )
558
+ return CallToolObservation(tool_name=action.tool_name, result=result)
559
  except asyncio.TimeoutError:
560
  return CallToolObservation(
561
  tool_name=action.tool_name,
 
565
  message=f"Tool '{action.tool_name}' timed out after {timeout} seconds",
566
  ),
567
  )
 
568
  except Exception as e:
569
  error_message = str(e)
 
 
570
  if (
571
  "not found" in error_message.lower()
572
  or "unknown tool" in error_message.lower()
 
579
  error_type = ToolErrorType.INVALID_ARGS
580
  else:
581
  error_type = ToolErrorType.EXECUTION_ERROR
 
582
  return CallToolObservation(
583
  tool_name=action.tool_name,
584
  result=None,
585
+ error=ToolError(error_type=error_type, message=error_message),
 
 
 
586
  )
587
 
588
+ async def step_async(
589
+ self,
590
+ action: Action,
591
+ timeout_s: Optional[float] = None,
592
+ **kwargs: Any,
593
+ ) -> Observation:
594
  """
595
+ Async step that routes MCP actions without going through run_async_safely.
596
 
597
+ The WebSocket handler calls this directly on the outer event loop, where
598
+ the MCP session is already open, avoiding the thread/event-loop deadlock
599
+ that occurs when the sync step() path is used via run_in_executor.
 
 
 
600
  """
601
+ if isinstance(action, ListToolsAction):
602
+ return await self._async_handle_list_tools()
603
+ elif isinstance(action, CallToolAction):
604
+ return await self._async_handle_call_tool(action, timeout_s=timeout_s)
605
+ else:
606
+ loop = asyncio.get_event_loop()
607
+ return await loop.run_in_executor(
608
+ None, lambda: self._step_impl(action, timeout_s=timeout_s, **kwargs)
609
+ )
610
 
611
  @abstractmethod
612
  def _step_impl(
src/openenv/core/env_server/web_interface.py CHANGED
@@ -22,7 +22,7 @@ from datetime import datetime
22
  from typing import Any, Callable, Dict, List, Optional, Type
23
 
24
  import gradio as gr
25
- from fastapi import Body, FastAPI, HTTPException, WebSocket, WebSocketDisconnect, status
26
  from fastapi.responses import RedirectResponse
27
  from pydantic import BaseModel, ConfigDict, Field
28
 
@@ -356,7 +356,9 @@ class WebInterfaceManager:
356
  else:
357
  # Run sync reset in thread pool to avoid blocking event loop
358
  # and to support environments using sync libraries (e.g., Playwright)
359
- observation = await self._run_sync_in_thread_pool(self.env.reset, **valid_kwargs)
 
 
360
  state: State = self.env.state
361
 
362
  # Serialize observation once using shared utility
 
22
  from typing import Any, Callable, Dict, List, Optional, Type
23
 
24
  import gradio as gr
25
+ from fastapi import Body, FastAPI, HTTPException, status, WebSocket, WebSocketDisconnect
26
  from fastapi.responses import RedirectResponse
27
  from pydantic import BaseModel, ConfigDict, Field
28
 
 
356
  else:
357
  # Run sync reset in thread pool to avoid blocking event loop
358
  # and to support environments using sync libraries (e.g., Playwright)
359
+ observation = await self._run_sync_in_thread_pool(
360
+ self.env.reset, **valid_kwargs
361
+ )
362
  state: State = self.env.state
363
 
364
  # Serialize observation once using shared utility
src/openenv_core.egg-info/PKG-INFO CHANGED
@@ -1,6 +1,6 @@
1
  Metadata-Version: 2.4
2
  Name: openenv-core
3
- Version: 0.2.3.dev0
4
  Summary: A unified framework for reinforcement learning environments
5
  Requires-Python: >=3.10
6
  Description-Content-Type: text/markdown
 
1
  Metadata-Version: 2.4
2
  Name: openenv-core
3
+ Version: 0.2.3
4
  Summary: A unified framework for reinforcement learning environments
5
  Requires-Python: >=3.10
6
  Description-Content-Type: text/markdown
src/openenv_core.egg-info/SOURCES.txt CHANGED
@@ -1,4 +1,5 @@
1
  LICENSE
 
2
  README.md
3
  pyproject.toml
4
  src/openenv/__init__.py
@@ -19,8 +20,6 @@ src/openenv/cli/commands/serve.py
19
  src/openenv/cli/commands/skills.py
20
  src/openenv/cli/commands/validate.py
21
  src/openenv/cli/templates/__init__.py
22
- src/openenv/cli/templates/__pycache__/__init__.cpython-311.pyc
23
- src/openenv/cli/templates/__pycache__/__init__.cpython-313.pyc
24
  src/openenv/cli/templates/openenv_env/README.md
25
  src/openenv/cli/templates/openenv_env/__init__.py
26
  src/openenv/cli/templates/openenv_env/client.py
 
1
  LICENSE
2
+ MANIFEST.in
3
  README.md
4
  pyproject.toml
5
  src/openenv/__init__.py
 
20
  src/openenv/cli/commands/skills.py
21
  src/openenv/cli/commands/validate.py
22
  src/openenv/cli/templates/__init__.py
 
 
23
  src/openenv/cli/templates/openenv_env/README.md
24
  src/openenv/cli/templates/openenv_env/__init__.py
25
  src/openenv/cli/templates/openenv_env/client.py