diff --git a/.github/unittest/llm/scripts_llm/environment.yml b/.github/unittest/llm/scripts_llm/environment.yml
index 2e6b3c6e173..f1b0131d40d 100644
--- a/.github/unittest/llm/scripts_llm/environment.yml
+++ b/.github/unittest/llm/scripts_llm/environment.yml
@@ -22,3 +22,4 @@ dependencies:
- transformers
- datasets
- vllm
+ - mcp
diff --git a/.github/unittest/llm/scripts_llm/install.sh b/.github/unittest/llm/scripts_llm/install.sh
index f5d23d2afbd..cf4f2372899 100644
--- a/.github/unittest/llm/scripts_llm/install.sh
+++ b/.github/unittest/llm/scripts_llm/install.sh
@@ -61,3 +61,17 @@ python -m pip install -e . --no-build-isolation
# smoke test
python -c "import torchrl"
+
+# Install MCP dependencies for tool execution tests
+printf "* Installing MCP dependencies (uvx, Deno)\n"
+
+# Install uvx (universal package runner)
+pip install uvx
+
+# Install Deno (required by mcp-run-python)
+curl -fsSL https://deno.land/install.sh | sh
+export PATH="$HOME/.deno/bin:$PATH"
+
+# Verify installations
+uvx --version || echo "Warning: uvx not installed"
+deno --version || echo "Warning: Deno not installed"
diff --git a/docs/source/reference/index.rst b/docs/source/reference/index.rst
index 53f4c246628..b1603fb6264 100644
--- a/docs/source/reference/index.rst
+++ b/docs/source/reference/index.rst
@@ -10,6 +10,7 @@ API Reference
llms
modules
objectives
+ services
trainers
utils
config
diff --git a/docs/source/reference/llms.rst b/docs/source/reference/llms.rst
index 4ab9409d62f..7dd464fd7db 100644
--- a/docs/source/reference/llms.rst
+++ b/docs/source/reference/llms.rst
@@ -930,7 +930,9 @@ Tools are usually implemented as transforms, and appended to a base environment
such as :class:`~torchrl.envs.llm.ChatEnv`.
An example of a tool transform is the :class:`~torchrl.envs.llm.transforms.PythonInterpreter` transform, which is used
-to execute Python code in the context of the LLM.
+to execute Python code in the context of the LLM. The PythonInterpreter can optionally use a shared
+:class:`~torchrl.envs.llm.transforms.PythonExecutorService` for efficient resource usage across multiple environments.
+See :ref:`ref_services` for more details on the service registry system.
>>> from torchrl.envs.llm.transforms import PythonInterpreter
>>> from torchrl.envs.llm import ChatEnv
@@ -1141,6 +1143,7 @@ By following these design principles, reward transforms can be effectively integ
KLRewardTransform
MCPToolTransform
PolicyVersion
+ PythonExecutorService
PythonInterpreter
RayDataLoadingPrimer
RetrieveKL
diff --git a/docs/source/reference/services.rst b/docs/source/reference/services.rst
new file mode 100644
index 00000000000..72d26fd3c54
--- /dev/null
+++ b/docs/source/reference/services.rst
@@ -0,0 +1,609 @@
+.. currentmodule:: torchrl
+
+Service Registry
+================
+
+.. _ref_services:
+
+TorchRL provides a service registry system for managing distributed services across workers in distributed applications.
+This is particularly useful for sharing resources like tokenizers, replay buffers, or Python executor pools across
+multiple environments or collectors.
+
+The service registry provides a **backend-agnostic API** for distributed service management. While the current
+implementation focuses on Ray as the primary backend, the design allows for future backends (e.g., Monarch,
+local multiprocessing) without changing the core API.
+
+Overview
+--------
+
+The service registry provides a centralized way to register and access distributed services that can be shared across
+different parts of your application. Services are registered once and can be accessed by any worker, with the underlying
+backend handling the distributed communication and resource management.
+
+**Current Backend Support:**
+
+- **Ray**: Full support for Ray-based distributed services (recommended for production use)
+- **Other backends**: Planned for future releases (e.g., Monarch, local multiprocessing)
+
+Key Features
+~~~~~~~~~~~~
+
+- **Centralized Management**: Register services once and access them from anywhere in your distributed system
+- **Namespace Isolation**: Services are isolated within namespaces for multi-tenant support
+- **Type Safety**: Dict-like access with ``services["name"]`` syntax
+- **Automatic Cleanup**: Reset all services in a namespace with a single call
+- **Backend Flexibility**: Designed to support multiple distributed backends (currently Ray)
+
+Basic Usage
+-----------
+
+Getting Started
+~~~~~~~~~~~~~~~
+
+The service registry API is backend-agnostic, but you need to specify which backend to use when getting the registry.
+Currently, Ray is the only supported backend.
+
+.. code-block:: python
+
+ import ray
+ from torchrl.services import get_services
+
+ # Initialize your backend (Ray in this example)
+ ray.init()
+
+ # Get the service registry for your chosen backend
+ services = get_services(backend="ray", namespace="my_namespace")
+
+ # Register a service (the class will become a distributed service)
+ services.register(
+ "tokenizer",
+ TokenizerService,
+ vocab_size=50000,
+ num_cpus=1, # Backend-specific option (Ray)
+ )
+
+ # Access the service from any worker
+ # (other workers just need to call get_services with the same backend and namespace)
+ services = get_services(backend="ray", namespace="my_namespace")
+ tokenizer = services["tokenizer"]
+
+ # Call the service (syntax depends on backend)
+ # For Ray, you need to use .remote() and ray.get()
+ result = ray.get(tokenizer.encode.remote("Hello world"))
+
+ # Cleanup when done
+ services.reset()
+ ray.shutdown()
+
+Service Registration
+~~~~~~~~~~~~~~~~~~~~
+
+Services are registered by providing a name, a class (that will become a distributed service), and any initialization arguments.
+The exact behavior depends on the backend being used.
+
+**Basic Registration (Backend-Agnostic):**
+
+.. code-block:: python
+
+ # Register a service with constructor arguments
+ services.register(
+ "my_service",
+ MyServiceClass,
+ arg1="value1",
+ arg2="value2",
+ )
+
+The ``register`` method accepts:
+
+- **name** (str): Unique identifier for the service
+- **service_factory** (type): Class to instantiate as a distributed service
+- **kwargs**: Arguments passed to the service constructor and/or backend-specific options
+
+**Backend-Specific Options (Ray):**
+
+When using the Ray backend, you can pass Ray actor options alongside constructor arguments:
+
+.. code-block:: python
+
+ # Ray-specific: Mix actor options and constructor arguments
+ services.register(
+ "gpu_service",
+ GPUService,
+ model_name="gpt2", # Constructor argument
+ num_cpus=4, # Ray actor option
+ num_gpus=1, # Ray actor option
+ max_concurrency=16, # Ray actor option
+ )
+
+For more explicit separation of backend options and constructor arguments, the Ray backend provides
+``register_with_options`` (note that options are expected not to collide with constructor arguments):
+
+.. code-block:: python
+
+ # Ray-specific: Explicit separation of options
+ services.register_with_options(
+ "my_service",
+ MyServiceClass,
+ actor_options={
+ "num_cpus": 4,
+ "num_gpus": 1,
+ "max_concurrency": 16,
+ },
+ model_name="gpt2", # Constructor argument
+ batch_size=32, # Constructor argument
+ )
+
+.. note::
+ The ``register_with_options`` method is specific to the Ray backend. Other backends may have
+ different mechanisms for separating backend options from constructor arguments.
+
+Service Access
+~~~~~~~~~~~~~~
+
+Services can be accessed using dict-like syntax:
+
+.. code-block:: python
+
+ # Check if service exists
+ if "tokenizer" in services:
+ tokenizer = services["tokenizer"]
+
+ # Get service (raises KeyError if not found)
+ tokenizer = services["tokenizer"]
+
+ # Alternative: use get() method
+ tokenizer = services.get("tokenizer")
+
+ # List all services
+ service_names = services.list()
+ print(f"Available services: {service_names}")
+
+Cross-Worker Visibility
+~~~~~~~~~~~~~~~~~~~~~~~
+
+Services registered by one worker are immediately visible to all other workers in the same namespace.
+This is a core feature of the service registry, enabled by the underlying distributed backend.
+
+**Example with Ray Backend:**
+
+.. code-block:: python
+
+ import ray
+ from torchrl.services import get_services
+
+ @ray.remote
+ class Worker:
+ def register_service(self):
+ # Worker 1: Register a service
+ services = get_services(backend="ray", namespace="shared")
+ services.register("shared_tokenizer", TokenizerService, vocab_size=50000)
+ return "registered"
+
+ def use_service(self):
+ # Worker 2: Use the service registered by Worker 1
+ services = get_services(backend="ray", namespace="shared")
+ tokenizer = services["shared_tokenizer"]
+ return ray.get(tokenizer.encode.remote("Hello"))
+
+ # Worker 1 registers the service
+ worker1 = Worker.remote()
+ ray.get(worker1.register_service.remote())
+
+ # Worker 2 can immediately use it
+ worker2 = Worker.remote()
+ result = ray.get(worker2.use_service.remote())
+
+The key insight is that both workers access the same service registry by using the same ``backend`` and
+``namespace`` parameters in ``get_services()``. The backend handles the distributed coordination.
+
+Namespace Isolation
+~~~~~~~~~~~~~~~~~~~
+
+Different namespaces provide complete isolation between service registries:
+
+.. code-block:: python
+
+ # Training namespace
+ train_services = get_services(backend="ray", namespace="training")
+ train_services.register("tokenizer", TokenizerService, vocab_size=50000)
+
+ # Evaluation namespace
+ eval_services = get_services(backend="ray", namespace="evaluation")
+ eval_services.register("tokenizer", TokenizerService, vocab_size=30000)
+
+ # These are completely independent services
+ assert "tokenizer" in train_services
+ assert "tokenizer" in eval_services
+ # But they have different configurations
+
+Cleanup
+~~~~~~~
+
+Always clean up services when done to free resources:
+
+.. code-block:: python
+
+ # Reset all services in a namespace
+ services.reset()
+
+ # This terminates all service actors and clears the registry
+ # After reset(), the registry is empty
+ assert services.list() == []
+
+Python Executor Service
+-----------------------
+
+One of the most useful built-in services is the :class:`~torchrl.envs.llm.transforms.PythonExecutorService`,
+which provides a shared pool of Python interpreter processes for executing code across multiple environments.
+This service is designed to work with any backend, though it's currently optimized for Ray.
+
+Overview
+~~~~~~~~
+
+The Python Executor Service allows you to share a fixed pool of Python interpreters (e.g., 32 processes) across
+many environments (e.g., 128 environments). This provides significant resource savings compared to giving each
+environment its own interpreter process. The service is registered through the service registry and can be
+accessed by any worker using the :class:`~torchrl.envs.llm.transforms.PythonInterpreter` transform.
+
+**Resource Efficiency:**
+
++---------------------------+---------------+------------+------------------+
+| Configuration | Environments | Processes | Resource Usage |
++===========================+===============+============+==================+
+| Local (persistent) | 128 | 128 | 100% |
++---------------------------+---------------+------------+------------------+
+| Service (pool=32) | 128 | 32 | **25%** |
++---------------------------+---------------+------------+------------------+
+| Service (pool=64) | 128 | 64 | **50%** |
++---------------------------+---------------+------------+------------------+
+
+Basic Usage
+~~~~~~~~~~~
+
+The Python Executor Service is registered like any other service, then accessed through the
+:class:`~torchrl.envs.llm.transforms.PythonInterpreter` transform by specifying ``services="ray"``
+(or the appropriate backend name).
+
+**Example with Ray Backend:**
+
+.. code-block:: python
+
+ import ray
+ from torchrl.services import get_services
+ from torchrl.envs.llm.transforms import PythonExecutorService, PythonInterpreter
+ from torchrl.envs.llm import ChatEnv
+
+ # Initialize your backend
+ ray.init()
+
+ # Register the Python executor service
+ services = get_services(backend="ray", namespace="my_namespace")
+ services.register(
+ "python_executor",
+ PythonExecutorService,
+ pool_size=32, # 32 interpreter processes
+ timeout=10.0, # 10 second timeout
+ num_cpus=32, # Ray-specific: Allocate 32 CPUs
+ max_concurrency=32, # Ray-specific: Allow 32 concurrent executions
+ )
+
+ # Create environments that use the service
+ env = ChatEnv(
+ batch_size=(128,), # 128 parallel environments
+ system_prompt="Execute Python code when requested.",
+ )
+
+ # Add PythonInterpreter transform configured to use the service
+ env = env.append_transform(
+ PythonInterpreter(
+ services="ray", # Use Ray backend
+ namespace="my_namespace", # Same namespace as registration
+ )
+ )
+
+ # All 128 environments now share the 32 interpreters!
+ # The backend (Ray) automatically queues requests when all interpreters are busy
+
+Optional Service Usage
+~~~~~~~~~~~~~~~~~~~~~~
+
+The :class:`~torchrl.envs.llm.transforms.PythonInterpreter` transform supports optional service usage.
+You can easily switch between using a shared service or local processes:
+
+.. code-block:: python
+
+ # Option 1: Use shared Ray service (recommended for many envs)
+ env = env.append_transform(
+ PythonInterpreter(
+ services="ray",
+ namespace="my_namespace",
+ )
+ )
+
+ # Option 2: Use local persistent processes (good for few envs)
+ env = env.append_transform(
+ PythonInterpreter(
+ services=None,
+ persistent=True,
+ )
+ )
+
+ # Option 3: Use temporary processes (good for infrequent use)
+ env = env.append_transform(
+ PythonInterpreter(
+ services=None,
+ persistent=False,
+ )
+ )
+
+Conditional Usage Pattern
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+You can decide at runtime whether to use a distributed service based on your configuration:
+
+.. code-block:: python
+
+ import ray
+ from torchrl.services import get_services
+ from torchrl.envs.llm.transforms import PythonExecutorService, PythonInterpreter
+
+ num_envs = 128
+ use_distributed_service = ray.is_initialized() and num_envs > 16
+
+ if use_distributed_service:
+ # Use distributed service for efficient resource usage
+ services = get_services(backend="ray") # Could be other backends in the future
+ if "python_executor" not in services:
+ services.register(
+ "python_executor",
+ PythonExecutorService,
+ pool_size=32,
+ timeout=10.0,
+ num_cpus=32, # Backend-specific option
+ max_concurrency=32, # Backend-specific option
+ )
+
+ # Configure transform to use the service
+ interpreter = PythonInterpreter(services="ray")
+ else:
+ # Use local processes (no distributed backend)
+ interpreter = PythonInterpreter(services=None, persistent=True)
+
+ env = env.append_transform(interpreter)
+
+How It Works
+~~~~~~~~~~~~
+
+The Python Executor Service uses a simple round-robin assignment strategy to distribute work across
+a pool of interpreter processes. The backend handles concurrency control and request queuing.
+
+**Architecture:**
+
+1. **Pool of Interpreters**: The service maintains a fixed pool of ``PersistentPythonProcess`` instances
+2. **Round-Robin Assignment**: Each request is assigned to the next interpreter in the pool
+3. **Backend Queuing**: When all interpreters are busy, the backend queues additional requests
+4. **Concurrent Execution**: The backend controls how many requests can execute simultaneously
+
+.. code-block:: python
+
+ # Inside PythonExecutorService
+ def execute(self, code: str) -> dict:
+ # Simple round-robin assignment
+ with self._lock:
+ process = self.processes[self.next_idx]
+ self.next_idx = (self.next_idx + 1) % self.pool_size
+
+ # Backend handles queuing (e.g., Ray's max_concurrency parameter)
+ return process.execute(code)
+
+**Backend-Specific Behavior:**
+
+- **Ray**: Uses the ``max_concurrency`` parameter to control concurrent executions. Requests beyond
+ this limit are automatically queued by Ray's actor system.
+- **Other backends**: Will have their own mechanisms for concurrency control and queuing.
+
+Performance Considerations
+~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+**When to Use Service Mode (Distributed):**
+
+- Running > 16 parallel environments
+- Resource efficiency is important
+- Code execution is frequent
+- Have a distributed backend available (e.g., Ray)
+
+**When to Use Local Persistent Mode:**
+
+- Running < 16 environments
+- Need strict isolation between environments
+- Latency is critical
+- Don't want distributed backend dependency
+
+**When to Use Local Temp File Mode:**
+
+- Code execution is infrequent
+- Don't want persistent processes
+- Memory is more important than speed
+
+Advanced Usage
+--------------
+
+Multiple Service Configurations
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+You can register multiple services with different configurations:
+
+.. code-block:: python
+
+ services = get_services(backend="ray")
+
+ # Fast service for simple code
+ services.register(
+ "python_executor_fast",
+ PythonExecutorService,
+ pool_size=16,
+ timeout=5.0,
+ num_cpus=16,
+ max_concurrency=16,
+ )
+
+ # Heavy service for complex code
+ services.register(
+ "python_executor_heavy",
+ PythonExecutorService,
+ pool_size=64,
+ timeout=30.0,
+ num_cpus=64,
+ max_concurrency=64,
+ )
+
+ # Use different services for different environments
+ fast_env = env.append_transform(
+ PythonInterpreter(services="ray", service_name="python_executor_fast")
+ )
+ heavy_env = env.append_transform(
+ PythonInterpreter(services="ray", service_name="python_executor_heavy")
+ )
+
+Custom Services
+~~~~~~~~~~~~~~~
+
+You can create your own services by defining a class and registering it:
+
+.. code-block:: python
+
+ class MyCustomService:
+ """A custom service for your application."""
+
+ def __init__(self, config: dict):
+ self.config = config
+ # Initialize your service
+
+ def process(self, data: str) -> dict:
+ # Process data and return results
+ return {"result": f"Processed: {data}"}
+
+ # Register the custom service
+ services = get_services(backend="ray")
+ services.register(
+ "my_service",
+ MyCustomService,
+ config={"param1": "value1"},
+ num_cpus=2,
+ )
+
+ # Use the service
+ my_service = services["my_service"]
+ result = ray.get(my_service.process.remote("Hello"))
+
+API Reference
+-------------
+
+Service Registry
+~~~~~~~~~~~~~~~~
+
+.. currentmodule:: torchrl.services
+
+.. autosummary::
+ :toctree: generated/
+ :template: rl_template.rst
+
+ get_services
+ reset_services
+ ServiceBase
+ RayService
+
+Python Executor Service
+~~~~~~~~~~~~~~~~~~~~~~~
+
+.. currentmodule:: torchrl.envs.llm.transforms
+
+.. autosummary::
+ :toctree: generated/
+ :template: rl_template.rst
+
+ PythonExecutorService
+ PythonInterpreter
+
+Best Practices
+--------------
+
+1. **Specify Backend and Namespace**: Always explicitly specify both the backend and namespace when calling
+ ``get_services()`` to ensure services are registered and accessed from the correct location.
+
+2. **Clean Up**: Always call ``services.reset()`` when done to free resources and terminate distributed services.
+
+3. **Service Naming**: Use descriptive names that indicate the service's purpose (e.g., ``"python_executor"``,
+ ``"tokenizer_service"``).
+
+4. **Backend-Specific Options**: Understand which options are backend-specific (e.g., ``num_cpus``, ``num_gpus``,
+ ``max_concurrency`` for Ray) and which are constructor arguments for your service class.
+
+5. **Error Handling**: Check if services exist before accessing them:
+
+ .. code-block:: python
+
+ if "my_service" in services:
+ service = services["my_service"]
+ else:
+ # Register or handle missing service
+
+6. **Conditional Registration**: Only register services if they don't already exist:
+
+ .. code-block:: python
+
+ if "python_executor" not in services:
+ services.register("python_executor", PythonExecutorService, ...)
+
+7. **Context Managers**: Consider using context managers for automatic cleanup:
+
+ .. code-block:: python
+
+ class ServiceContext:
+ def __init__(self, backend, namespace):
+ self.services = get_services(backend=backend, namespace=namespace)
+
+ def __enter__(self):
+ return self.services
+
+ def __exit__(self, *args):
+ self.services.reset()
+
+ with ServiceContext("ray", "my_namespace") as services:
+ services.register("my_service", MyService)
+ # Use services...
+ # Automatic cleanup
+
+8. **Backend Portability**: When writing code that should work with multiple backends, avoid using
+ backend-specific methods like ``register_with_options()`` (Ray-only). Stick to the common ``register()``
+ API for maximum portability.
+
+Examples
+--------
+
+For complete examples, see:
+
+- ``examples/services/distributed_services.py`` - Basic service registry usage
+- ``examples/llm/python_executor_service.py`` - Python executor service examples
+- ``test/test_services.py`` - Comprehensive test suite
+- ``test/test_python_executor_service.py`` - Python executor service tests
+
+See Also
+--------
+
+- :ref:`ref_llms` - LLM API documentation
+- :ref:`ref_collectors` - Collector documentation
+- `Ray Documentation