diff --git a/docs/getting-started.md b/docs/getting-started.md index 47f351208..814e4ebbb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -138,6 +138,7 @@ This will display: | `other_backend_kwargs` | `list` | `None` | Configs for additional backends | | `logger` | `RLMLogger` | `None` | Logger for trajectory tracking | | `verbose` | `bool` | `False` | Enable console output | +| `custom_tools` | `dict` | `None` | Custom functions/data available in REPL | ### The `completion()` Method @@ -285,6 +286,90 @@ rlm = RLM( --- +## Custom Tools + +You can provide custom functions and data that the RLM can use in its REPL environment. This allows you to give the model access to domain-specific tools, APIs, or helper functions. + +### Basic Usage + +```python +def fetch_weather(city: str) -> str: + """Fetch weather data for a city.""" + # Your API call here + return f"Weather in {city}: Sunny, 72°F" + +def calculate_shipping(weight: float, distance: float) -> float: + """Calculate shipping cost.""" + return weight * 0.5 + distance * 0.1 + +rlm = RLM( + backend="openai", + backend_kwargs={"model_name": "gpt-4o"}, + custom_tools={ + "fetch_weather": fetch_weather, + "calculate_shipping": calculate_shipping, + "API_KEY": "your-api-key", # Non-callable values become variables + }, +) + +result = rlm.completion("What's the weather in Tokyo and calculate shipping for 10kg over 500km?") +``` + +Inside the REPL, the model can now call: +```python +weather = fetch_weather("Tokyo") +cost = calculate_shipping(10, 500) +``` + +### Tool Descriptions + +You can provide descriptions for your tools that will be included in the system prompt, helping the model understand what each tool does: + +```python +rlm = RLM( + backend="openai", + backend_kwargs={"model_name": "gpt-4o"}, + custom_tools={ + # Dict format: {"tool": callable_or_value, "description": "..."} + "fetch_weather": {"tool": fetch_weather, "description": "Fetch current weather data for a city name"}, + "calculate_shipping": {"tool": calculate_shipping, "description": "Calculate shipping cost given weight (kg) and distance (km)"}, + "API_KEY": {"tool": "your-api-key", "description": "API key for the weather service"}, + }, +) +``` + +The descriptions are automatically added to the system prompt: +``` +6. Custom tools and data available in the REPL: +- `fetch_weather`: Fetch current weather data for a city name +- `calculate_shipping`: Calculate shipping cost given weight (kg) and distance (km) +- `API_KEY`: API key for the weather service +``` + +### Isolated Environments (Modal, Daytona) + +For isolated environments, custom tools must be serializable. You can provide: + +1. **Code strings** - Python code that defines the function: +```python +custom_tools = { + "helper": ''' +def helper(x): + return x * 2 +''', +} +``` + +2. **Serializable data** - JSON-compatible values (strings, numbers, dicts, lists): +```python +custom_tools = { + "CONFIG": {"api_url": "https://api.example.com", "timeout": 30}, + "ALLOWED_CITIES": ["Tokyo", "London", "New York"], +} +``` + +--- + ## Logging and Debugging ### Enable Logging diff --git a/docs/src/app/environments/page.tsx b/docs/src/app/environments/page.tsx index 54e171ec5..70e032243 100644 --- a/docs/src/app/environments/page.tsx +++ b/docs/src/app/environments/page.tsx @@ -99,16 +99,20 @@ export default function EnvironmentsPage() { ], [ llm_query(prompt, model=None), - "Query a sub-LM from within the REPL. Returns the completion string." + "Single LM completion call. Returns the completion string. Does not have tool access." ], [ llm_query_batched(prompts, model=None), - "Concurrent sub-LM queries. Returns a list of completion strings." + "Concurrent single LM completion calls. Returns a list of completion strings. Does not have tool access." ], [ FINAL_VAR(var_name), "Mark a variable as the final answer to return from the RLM" ], + [ + custom_tools, + "Any custom functions or data you provide via the custom_tools parameter" + ], ]} /> @@ -119,6 +123,9 @@ context = "Your input here" # Query a sub-LM result = llm_query("Summarize the context", model="gpt-5-mini") +# Use a custom tool (if provided) +data = fetch_data(context["url"]) # Custom function + # Process the result summary = process(result) @@ -126,6 +133,48 @@ summary = process(result) FINAL_VAR(summary)`} /> +
+

Custom Tools

+ +

+ You can provide custom functions and data that the RLM can use in its REPL environment + via the custom_tools parameter: +

+ + str: + """Fetch weather data for a city.""" + return f"Weather in {city}: Sunny, 72°F" + +rlm = RLM( + backend="openai", + backend_kwargs={"model_name": "gpt-4o"}, + custom_tools={ + # Plain format (no description) + "fetch_weather": fetch_weather, + + # Dict format with description (recommended) + "calculate_tip": {"tool": lambda x: x * 0.2, "description": "Calculate 20% tip for a bill amount"}, + "API_KEY": {"tool": "your-key", "description": "API key for external services"}, + }, +) + +# The model can now call fetch_weather() in its REPL code`} /> + +

+ Tool descriptions: Use the dict format + {`{"tool": value, "description": "..."}`} to + provide descriptions that help the model understand what each tool does. Descriptions are automatically + included in the system prompt. +

+ +

+ Note: llm_query() calls + are single LM completions and do not have access to custom tools. Only the main RLM execution context has tool access. +

+
+

Architecture

diff --git a/examples/custom_tools_example.py b/examples/custom_tools_example.py new file mode 100644 index 000000000..2aadf0b8f --- /dev/null +++ b/examples/custom_tools_example.py @@ -0,0 +1,133 @@ +""" +Example demonstrating custom tools in RLM. + +Custom tools allow you to provide domain-specific functions and data +that the RLM can use in its REPL environment. + +Run with: python -m examples.custom_tools_example +""" + +import os +from typing import Any + +from dotenv import load_dotenv + +from rlm import RLM + +load_dotenv() + + +# ============================================================================= +# Define Custom Tools +# ============================================================================= + + +def fetch_stock_price(symbol: str) -> dict[str, Any]: + """ + Fetch stock price data for a given symbol. + In a real application, this would call a financial API. + """ + # Mock data for demonstration + prices = { + "AAPL": {"price": 178.50, "change": 2.3, "volume": 52_000_000}, + "GOOGL": {"price": 141.25, "change": -0.8, "volume": 21_000_000}, + "MSFT": {"price": 378.90, "change": 1.5, "volume": 18_000_000}, + "AMZN": {"price": 178.75, "change": 0.2, "volume": 35_000_000}, + } + return prices.get(symbol.upper(), {"error": f"Symbol {symbol} not found"}) + + +def calculate_portfolio_value(holdings: dict[str, int]) -> float: + """ + Calculate total portfolio value given holdings. + holdings: dict mapping symbol to number of shares + """ + total = 0.0 + for symbol, shares in holdings.items(): + data = fetch_stock_price(symbol) + if "price" in data: + total += data["price"] * shares + return total + + +def format_currency(amount: float) -> str: + """Format a number as USD currency.""" + return f"${amount:,.2f}" + + +# Configuration data (non-callable values become variables) +MARKET_CONFIG = { + "trading_hours": {"open": "09:30", "close": "16:00"}, + "currency": "USD", + "exchange": "NYSE", +} + + +# ============================================================================= +# Example 1: Basic Custom Tools +# ============================================================================= + + +def example_basic_tools(): + """Demonstrate basic custom tools usage with descriptions.""" + print("=" * 60) + print("Example 1: Basic Custom Tools with Descriptions") + print("=" * 60) + + # Tools can be provided with descriptions using dict format: + # {"name": {"tool": callable_or_value, "description": "..."}} + # The description will be included in the system prompt so the model + # knows what each tool does. + + rlm = RLM( + backend="portkey", + backend_kwargs={ + "model_name": "@openai/gpt-5-nano", + "api_key": os.getenv("PORTKEY_API_KEY"), + }, + environment="local", + custom_tools={ + # Callable functions with descriptions (dict format) + "fetch_stock_price": { + "tool": fetch_stock_price, + "description": "Fetch current stock price data for a symbol (AAPL, GOOGL, MSFT, AMZN)", + }, + "calculate_portfolio_value": { + "tool": calculate_portfolio_value, + "description": "Calculate total portfolio value from a dict of {symbol: shares}", + }, + "format_currency": { + "tool": format_currency, + "description": "Format a number as USD currency string", + }, + # Data values with descriptions + "MARKET_CONFIG": { + "tool": MARKET_CONFIG, + "description": "Market configuration including trading hours and exchange info", + }, + }, + verbose=True, + ) + + # The model can now use these tools to answer questions + result = rlm.completion( + "What's the current price of AAPL and GOOGL? " + "Calculate the total value of a portfolio with 100 shares of each. " + "Format the result as currency." + ) + + print(f"\nFinal Answer: {result.response}") + + +# ============================================================================= +# Main +# ============================================================================= + + +if __name__ == "__main__": + if not os.getenv("OPENAI_API_KEY"): + print("Please set OPENAI_API_KEY environment variable") + print("You can also modify this example to use a different backend") + exit(1) + + example_basic_tools() diff --git a/examples/quickstart.py b/examples/quickstart.py index 10150a782..d3d428204 100644 --- a/examples/quickstart.py +++ b/examples/quickstart.py @@ -10,10 +10,10 @@ logger = RLMLogger(log_dir="./logs") rlm = RLM( - backend="openai", # or "portkey", etc. + backend="portkey", # or "openai", etc. backend_kwargs={ - "model_name": "gpt-5-nano", - "api_key": os.getenv("OPENAI_API_KEY"), + "model_name": "@openai/gpt-5-nano", + "api_key": os.getenv("PORTKEY_API_KEY"), }, environment="local", environment_kwargs={}, diff --git a/rlm/core/rlm.py b/rlm/core/rlm.py index 5f303928e..0cdf4010f 100644 --- a/rlm/core/rlm.py +++ b/rlm/core/rlm.py @@ -52,6 +52,8 @@ def __init__( logger: RLMLogger | None = None, verbose: bool = False, persistent: bool = False, + custom_tools: dict[str, Any] | None = None, + custom_sub_tools: dict[str, Any] | None = None, ): """ Args: @@ -68,6 +70,10 @@ def __init__( logger: The logger to use for the RLM. verbose: Whether to print verbose output in rich to console. persistent: If True, reuse the environment across completion() calls for multi-turn conversations. + custom_tools: Dict of custom functions/tools available in the REPL. Keys are function names, + values are callable functions. These are injected into the REPL globals. + custom_sub_tools: Dict of custom tools for sub-agents (llm_query calls). If None, inherits + from custom_tools. Pass an empty dict {} to disable tools for sub-agents. """ # Store config for spawning per-completion self.backend = backend @@ -87,6 +93,11 @@ def __init__( self.other_backends = other_backends self.other_backend_kwargs = other_backend_kwargs + # Custom tools: functions available in the REPL environment + self.custom_tools = custom_tools + # Sub-tools: if None, inherit from custom_tools; if {}, no tools for sub-agents + self.custom_sub_tools = custom_sub_tools if custom_sub_tools is not None else custom_tools + self.depth = depth self.max_depth = max_depth self.max_iterations = max_iterations @@ -165,6 +176,11 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]): env_kwargs["lm_handler_address"] = (lm_handler.host, lm_handler.port) env_kwargs["context_payload"] = prompt env_kwargs["depth"] = self.depth + 1 # Environment depth is RLM depth + 1 + # Pass custom tools to the environment + if self.custom_tools is not None: + env_kwargs["custom_tools"] = self.custom_tools + if self.custom_sub_tools is not None: + env_kwargs["custom_sub_tools"] = self.custom_sub_tools environment: BaseEnv = get_environment(self.environment_type, env_kwargs) if self.persistent: @@ -184,7 +200,9 @@ def _setup_prompt(self, prompt: str | dict[str, Any]) -> list[dict[str, Any]]: """ metadata = QueryMetadata(prompt) message_history = build_rlm_system_prompt( - system_prompt=self.system_prompt, query_metadata=metadata + system_prompt=self.system_prompt, + query_metadata=metadata, + custom_tools=self.custom_tools, ) return message_history diff --git a/rlm/environments/__init__.py b/rlm/environments/__init__.py index 0b28629e5..3b769b7b2 100644 --- a/rlm/environments/__init__.py +++ b/rlm/environments/__init__.py @@ -1,9 +1,33 @@ from typing import Any, Literal -from rlm.environments.base_env import BaseEnv, SupportsPersistence +from rlm.environments.base_env import ( + RESERVED_TOOL_NAMES, + BaseEnv, + SupportsCustomTools, + SupportsPersistence, + ToolInfo, + extract_tool_value, + format_tools_for_prompt, + parse_custom_tools, + parse_tool_entry, + validate_custom_tools, +) from rlm.environments.local_repl import LocalREPL -__all__ = ["BaseEnv", "LocalREPL", "SupportsPersistence", "get_environment"] +__all__ = [ + "BaseEnv", + "LocalREPL", + "RESERVED_TOOL_NAMES", + "SupportsCustomTools", + "SupportsPersistence", + "ToolInfo", + "extract_tool_value", + "format_tools_for_prompt", + "get_environment", + "parse_custom_tools", + "parse_tool_entry", + "validate_custom_tools", +] def get_environment( diff --git a/rlm/environments/base_env.py b/rlm/environments/base_env.py index e7b1be8fd..103199601 100644 --- a/rlm/environments/base_env.py +++ b/rlm/environments/base_env.py @@ -1,13 +1,211 @@ from abc import ABC, abstractmethod +from dataclasses import dataclass from typing import Any, Protocol, runtime_checkable from rlm.core.types import REPLResult +# ============================================================================= +# Custom Tools Support +# ============================================================================= + +# Reserved names that cannot be overridden by custom tools. +# These are the built-in REPL functions that all environments must provide. +RESERVED_TOOL_NAMES: frozenset[str] = frozenset( + { + "llm_query", + "llm_query_batched", + "FINAL_VAR", + "SHOW_VARS", + "context", + } +) + + +@dataclass +class ToolInfo: + """Parsed information about a custom tool.""" + + name: str + value: Any + description: str | None = None + + @property + def is_callable(self) -> bool: + """Check if the tool value is callable.""" + return callable(self.value) + + +def parse_tool_entry(name: str, entry: Any) -> ToolInfo: + """ + Parse a custom tool entry into its components. + + Supports two formats: + 1. Just the value: {"name": callable_or_value} + 2. Dict with description: {"name": {"tool": callable_or_value, "description": "..."}} + + Args: + name: The tool name. + entry: The tool entry (value or dict with "tool" and "description" keys). + + Returns: + ToolInfo with parsed components. + """ + if isinstance(entry, dict) and "tool" in entry: + value = entry["tool"] + description = entry.get("description") + if description is not None and isinstance(description, str): + return ToolInfo(name=name, value=value, description=description) + return ToolInfo(name=name, value=value, description=None) + # No description - treat as plain value + return ToolInfo(name=name, value=entry, description=None) + + +def parse_custom_tools(custom_tools: dict[str, Any] | None) -> list[ToolInfo]: + """ + Parse all custom tools into ToolInfo objects. + + Args: + custom_tools: Dictionary of tool names to values or {"tool": ..., "description": ...} dicts. + + Returns: + List of ToolInfo objects. + """ + if custom_tools is None: + return [] + return [parse_tool_entry(name, entry) for name, entry in custom_tools.items()] + + +def extract_tool_value(entry: Any) -> Any: + """ + Extract the actual value from a tool entry. + + Args: + entry: The tool entry (value or {"tool": ..., "description": ...} dict). + + Returns: + The tool value (callable or data). + """ + if isinstance(entry, dict) and "tool" in entry: + return entry["tool"] + return entry + + +def format_tools_for_prompt(custom_tools: dict[str, Any] | None) -> str | None: + """ + Format custom tools for inclusion in the system prompt. + + Args: + custom_tools: Dictionary of tool names to values or {"tool": ..., "description": ...} dicts. + + Returns: + Formatted string describing available tools, or None if no tools. + """ + if not custom_tools: + return None + + tool_infos = parse_custom_tools(custom_tools) + if not tool_infos: + return None + + lines = [] + for tool in tool_infos: + if tool.is_callable: + if tool.description: + lines.append(f"- `{tool.name}`: {tool.description}") + else: + lines.append(f"- `{tool.name}`: A custom function") + else: + if tool.description: + lines.append(f"- `{tool.name}`: {tool.description}") + else: + type_name = type(tool.value).__name__ + lines.append(f"- `{tool.name}`: A custom {type_name} value") + + return "\n".join(lines) + + +def validate_custom_tools(custom_tools: dict[str, Any] | None) -> None: + """ + Validate that custom tools don't override reserved REPL functions. + + Args: + custom_tools: Dictionary of custom tool names to their implementations. + + Raises: + ValueError: If any tool name conflicts with reserved names. + """ + if custom_tools is None: + return + + conflicts = set(custom_tools.keys()) & RESERVED_TOOL_NAMES + if conflicts: + raise ValueError( + f"Custom tools cannot override reserved REPL functions: {sorted(conflicts)}. " + f"Reserved names: {sorted(RESERVED_TOOL_NAMES)}" + ) + + +@runtime_checkable +class SupportsCustomTools(Protocol): + """Protocol for environments that support custom tools. + + Custom tools allow users to inject their own functions and data into the + REPL environment, making them available to code executed by the RLM. + + CHECKING SUPPORT: + Use isinstance(env, SupportsCustomTools) to check if an environment + supports custom tools. + + TOOL TYPES: + - Callable values: Injected as functions the model can call + - Non-callable values: Injected as data/variables + + TOOL FORMAT: + Tools can be specified in two formats: + 1. Plain value: {"name": callable_or_value} + 2. With description: {"name": {"tool": callable_or_value, "description": "..."}} + + NOTE ON llm_query: + llm_query() calls are single LM completions and do NOT have access to + custom tools. Only the main RLM execution context has tool access. + + RESERVED NAMES: + The following names cannot be used as custom tool names: + - llm_query, llm_query_batched: Single LM completion functions (no tool access) + - FINAL_VAR, SHOW_VARS: Built-in helper functions + - context: The input context variable + + EXAMPLE: + custom_tools = { + "fetch_data": my_fetch_function, # Plain callable + "API_KEY": "sk-...", # Plain value + "calculator": { # With description + "tool": calc_function, + "description": "Performs arithmetic calculations", + }, + } + """ + + custom_tools: dict[str, Any] + class BaseEnv(ABC): """ Base REPL-like environment that the RLM uses to interact with. The primary types are isolated and non-isolated, where isolated environments are on a separate machine from the LM. + + Custom Tools: + Environments can accept `custom_tools` kwargs: + - custom_tools: Dict[str, Any] - Functions/values available in the REPL. + Callable values are added to globals, non-callable to locals. + + Note: llm_query() calls are single LM completions without tool access. + + Example: + custom_tools = { + "fetch_data": my_fetch_function, + "API_KEY": "sk-...", # Non-callable, added to locals + } """ def __init__(self, persistent: bool = False, depth: int = 1, **kwargs): diff --git a/rlm/environments/daytona_repl.py b/rlm/environments/daytona_repl.py index f5cf267a4..0e489c3c5 100644 --- a/rlm/environments/daytona_repl.py +++ b/rlm/environments/daytona_repl.py @@ -10,6 +10,7 @@ import textwrap import threading import time +from typing import Any import requests from daytona import ( @@ -23,7 +24,7 @@ from rlm.core.comms_utils import LMRequest, send_lm_request, send_lm_request_batched from rlm.core.types import REPLResult, RLMChatCompletion -from rlm.environments.base_env import IsolatedEnv +from rlm.environments.base_env import IsolatedEnv, extract_tool_value, validate_custom_tools # ============================================================================= # Default Daytona Image @@ -152,13 +153,55 @@ def respond(): # ============================================================================= -def _build_exec_script(code: str, broker_port: int = 8080, depth: int = 1) -> str: +def _build_exec_script( + code: str, + broker_port: int = 8080, + depth: int = 1, + custom_tools: dict[str, Any] | None = None, +) -> str: """ Build a script that executes code with state persistence. LLM queries go through the local broker server. + + Args: + code: The Python code to execute. + broker_port: Port for the broker server. + depth: Depth level for LLM requests. + custom_tools: Dict of custom tools. Values can be: + - Strings: Interpreted as Python code defining the tool (executed directly) + - Other values: JSON-serialized and loaded as data """ code_b64 = base64.b64encode(code.encode()).decode() + # Build custom tools injection code + custom_tools_code = "" + if custom_tools: + tool_lines = [] + for name, entry in custom_tools.items(): + # Extract value from (value, description) tuple if needed + value = extract_tool_value(entry) + + if isinstance(value, str) and ( + value.strip().startswith("def ") + or value.strip().startswith("class ") + or value.strip().startswith("lambda") + or "\n" in value + ): + # String looks like code - execute it directly + tool_lines.append(f"# Custom tool: {name}") + tool_lines.append(value) + tool_lines.append(f"_globals['{name}'] = {name}") + else: + # Serialize as JSON data + try: + json_value = json.dumps(value) + tool_lines.append(f"_locals['{name}'] = json.loads('''{json_value}''')") + except (TypeError, ValueError): + # Can't serialize - skip with warning + tool_lines.append(f"# Warning: Could not serialize tool '{name}'") + + custom_tools_code = "\n".join(tool_lines) + return textwrap.dedent( f''' import sys @@ -281,6 +324,11 @@ def SHOW_VARS(): "SHOW_VARS": SHOW_VARS, }} +# ============================================================================= +# Custom Tools Injection +# ============================================================================= +{custom_tools_code} + code = base64.b64decode("{code_b64}").decode() stdout_buf = io.StringIO() @@ -341,6 +389,8 @@ def __init__( setup_code: str | None = None, persistent: bool = False, depth: int = 1, + custom_tools: dict[str, Any] | None = None, + custom_sub_tools: dict[str, Any] | None = None, **kwargs, ): """ @@ -361,6 +411,10 @@ def __init__( setup_code: Optional code to run during setup. persistent: Whether to persist state across calls (not yet supported). depth: Depth level for LLM request routing (used by LMHandler). + custom_tools: Dict of custom tools available in the REPL. For isolated environments, + values should be strings containing Python code that defines the function, + or simple serializable values (str, int, dict, list). + custom_sub_tools: Dict of tools for sub-agents. If None, inherits from custom_tools. **kwargs: Additional arguments passed to base class. """ if persistent: @@ -380,6 +434,15 @@ def __init__( self.image = image or get_default_image() self.lm_handler_address = lm_handler_address + # Custom tools for the REPL environment + self.custom_tools = custom_tools or {} + self.custom_sub_tools = ( + custom_sub_tools if custom_sub_tools is not None else self.custom_tools + ) + + # Validate custom tools don't override reserved names + validate_custom_tools(self.custom_tools) + self.daytona = None self.sandbox = None self.broker_session_id: str = "rlm-broker-session" @@ -559,7 +622,9 @@ def execute_code(self, code: str) -> REPLResult: self.pending_llm_calls.clear() # Build and execute the script - script = _build_exec_script(code, self.BROKER_PORT, self.depth) + script = _build_exec_script( + code, self.BROKER_PORT, self.depth, custom_tools=self.custom_tools + ) # Upload the script as a temporary file script_path = "/tmp/rlm_exec_script.py" diff --git a/rlm/environments/local_repl.py b/rlm/environments/local_repl.py index c37cd2c3b..cc712ec74 100644 --- a/rlm/environments/local_repl.py +++ b/rlm/environments/local_repl.py @@ -13,7 +13,7 @@ from rlm.core.comms_utils import LMRequest, send_lm_request, send_lm_request_batched from rlm.core.types import REPLResult, RLMChatCompletion -from rlm.environments.base_env import NonIsolatedEnv +from rlm.environments.base_env import NonIsolatedEnv, extract_tool_value, validate_custom_tools # ============================================================================= # Safe Builtins @@ -125,6 +125,8 @@ def __init__( setup_code: str | None = None, persistent: bool = False, depth: int = 1, + custom_tools: dict[str, Any] | None = None, + custom_sub_tools: dict[str, Any] | None = None, **kwargs, ): super().__init__(persistent=persistent, depth=depth, **kwargs) @@ -136,6 +138,16 @@ def __init__( self._context_count: int = 0 self._history_count: int = 0 + # Custom tools: functions available in the REPL + self.custom_tools = custom_tools or {} + # Sub-tools: inherited from custom_tools if not specified + self.custom_sub_tools = ( + custom_sub_tools if custom_sub_tools is not None else self.custom_tools + ) + + # Validate custom tools don't override reserved names + validate_custom_tools(self.custom_tools) + # Setup globals, locals, and modules in environment. self.setup() @@ -165,6 +177,16 @@ def setup(self): self.globals["llm_query"] = self._llm_query self.globals["llm_query_batched"] = self._llm_query_batched + # Add custom tools to globals + # Tools can be either plain values or (value, description) tuples + for name, entry in self.custom_tools.items(): + value = extract_tool_value(entry) + if callable(value): + self.globals[name] = value + else: + # For non-callable values (constants, data), add to locals + self.locals[name] = value + def _final_var(self, variable_name: str) -> str: """Return the value of a variable as a final answer.""" variable_name = variable_name.strip().strip("\"'") @@ -397,8 +419,10 @@ def cleanup(self): shutil.rmtree(self.temp_dir) except Exception: pass - self.globals.clear() - self.locals.clear() + if hasattr(self, "globals"): + self.globals.clear() + if hasattr(self, "locals"): + self.locals.clear() def __del__(self): self.cleanup() diff --git a/rlm/utils/prompts.py b/rlm/utils/prompts.py index 5e6bfdc93..1cfb372bb 100644 --- a/rlm/utils/prompts.py +++ b/rlm/utils/prompts.py @@ -1,4 +1,5 @@ import textwrap +from typing import Any from rlm.core.types import QueryMetadata @@ -12,6 +13,7 @@ 3. A `llm_query_batched` function that allows you to query multiple prompts concurrently: `llm_query_batched(prompts: List[str]) -> List[str]`. This is much faster than sequential `llm_query` calls when you have multiple independent queries. Results are returned in the same order as the input prompts. 4. A `SHOW_VARS()` function that returns all variables you have created in the REPL. Use this to check what variables exist before using FINAL_VAR. 5. The ability to use `print()` statements to view the output of your REPL code and continue your reasoning. +{custom_tools_section} You will only be able to see truncated outputs from the REPL environment, so you should use the query LLM function on variables you want to analyze. You will find this function especially useful when you have to analyze the semantics of the context. Use these variables as buffers to build up your final answer. Make sure to explicitly look through the entire context in REPL before answering your query. An example strategy is to first look at the context and figure out a chunking strategy, then break up the context into smart chunks, and query an LLM per chunk with a particular question and save the answers to a buffer, then query an LLM with all the buffers to produce your final answer. @@ -94,16 +96,20 @@ def build_rlm_system_prompt( system_prompt: str, query_metadata: QueryMetadata, + custom_tools: dict[str, Any] | None = None, ) -> list[dict[str, str]]: """ Build the initial system prompt for the REPL environment based on extra prompt metadata. Args: - query_metadata: QueryMetadata object containing context metadata + system_prompt: The base system prompt template. + query_metadata: QueryMetadata object containing context metadata. + custom_tools: Optional dict of custom tools to include in the prompt. Returns: List of message dictionaries """ + from rlm.environments.base_env import format_tools_for_prompt context_lengths = query_metadata.context_lengths context_total_length = query_metadata.context_total_length @@ -114,10 +120,22 @@ def build_rlm_system_prompt( others = len(context_lengths) - 100 context_lengths = str(context_lengths[:100]) + "... [" + str(others) + " others]" + # Format custom tools section if provided + tools_formatted = format_tools_for_prompt(custom_tools) + if tools_formatted: + custom_tools_section = ( + f"\n6. Custom tools and data available in the REPL:\n{tools_formatted}" + ) + else: + custom_tools_section = "" + + # Insert custom tools section into the system prompt + final_system_prompt = system_prompt.format(custom_tools_section=custom_tools_section) + metadata_prompt = f"Your context is a {context_type} with {context_total_length} total characters, and is broken up into chunks of char lengths: {context_lengths}." return [ - {"role": "system", "content": system_prompt}, + {"role": "system", "content": final_system_prompt}, {"role": "assistant", "content": metadata_prompt}, ] diff --git a/tests/repl/test_custom_tools.py b/tests/repl/test_custom_tools.py new file mode 100644 index 000000000..7589385c1 --- /dev/null +++ b/tests/repl/test_custom_tools.py @@ -0,0 +1,603 @@ +""" +Tests for custom tools support in REPL environments. + +These tests verify that all REPL implementations correctly handle custom tools: +- Callable tools are available as functions +- Non-callable tools are available as variables +- Reserved names cannot be overridden + +Run with: uv run pytest tests/repl/test_custom_tools.py -v +""" + +import pytest + +from rlm.environments import ( + RESERVED_TOOL_NAMES, + SupportsCustomTools, + extract_tool_value, + format_tools_for_prompt, + parse_custom_tools, + parse_tool_entry, + validate_custom_tools, +) +from rlm.environments.local_repl import LocalREPL + +# ============================================================================= +# Test Fixtures +# ============================================================================= + + +def sample_tool_function(x: int) -> int: + """A simple test function.""" + return x * 2 + + +def another_tool_function(a: str, b: str) -> str: + """Another test function.""" + return f"{a}-{b}" + + +@pytest.fixture +def custom_tools(): + """Standard set of custom tools for testing.""" + return { + "double": sample_tool_function, + "concat": another_tool_function, + "CONFIG": {"key": "value", "number": 42}, + "CONSTANT": "hello world", + } + + +# ============================================================================= +# Base Validation Tests (no environment needed) +# ============================================================================= + + +class TestValidateCustomTools: + """Tests for the validate_custom_tools function.""" + + def test_none_is_valid(self): + """None custom_tools should pass validation.""" + validate_custom_tools(None) # Should not raise + + def test_empty_dict_is_valid(self): + """Empty dict should pass validation.""" + validate_custom_tools({}) # Should not raise + + def test_valid_tools_pass(self, custom_tools): + """Valid custom tools should pass validation.""" + validate_custom_tools(custom_tools) # Should not raise + + @pytest.mark.parametrize("reserved_name", list(RESERVED_TOOL_NAMES)) + def test_reserved_names_rejected(self, reserved_name): + """Each reserved name should be rejected.""" + with pytest.raises(ValueError) as exc_info: + validate_custom_tools({reserved_name: lambda: None}) + assert reserved_name in str(exc_info.value) + assert "reserved" in str(exc_info.value).lower() + + def test_multiple_reserved_names_all_reported(self): + """When multiple reserved names are used, all should be reported.""" + tools = {"llm_query": 1, "FINAL_VAR": 2, "valid_name": 3} + with pytest.raises(ValueError) as exc_info: + validate_custom_tools(tools) + error_msg = str(exc_info.value) + assert "llm_query" in error_msg + assert "FINAL_VAR" in error_msg + + def test_reserved_names_constant_is_frozen(self): + """RESERVED_TOOL_NAMES should be immutable.""" + assert isinstance(RESERVED_TOOL_NAMES, frozenset) + with pytest.raises(AttributeError): + RESERVED_TOOL_NAMES.add("new_name") + + +# ============================================================================= +# REPL Environment Tests +# ============================================================================= + + +class TestLocalREPLCustomTools: + """Tests for custom tools in LocalREPL.""" + + def test_supports_custom_tools_protocol(self): + """LocalREPL should implement SupportsCustomTools protocol.""" + repl = LocalREPL(custom_tools={"test": lambda: 1}) + assert isinstance(repl, SupportsCustomTools) + repl.cleanup() + + def test_callable_tool_available(self, custom_tools): + """Callable tools should be available as functions.""" + repl = LocalREPL(custom_tools=custom_tools) + + result = repl.execute_code("result = double(21)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == 42 + + repl.cleanup() + + def test_callable_tool_with_args(self, custom_tools): + """Callable tools should work with multiple arguments.""" + repl = LocalREPL(custom_tools=custom_tools) + + result = repl.execute_code('result = concat("hello", "world")') + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == "hello-world" + + repl.cleanup() + + def test_non_callable_dict_available(self, custom_tools): + """Non-callable dict tools should be available as variables.""" + repl = LocalREPL(custom_tools=custom_tools) + + result = repl.execute_code('result = CONFIG["key"]') + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == "value" + + repl.cleanup() + + def test_non_callable_string_available(self, custom_tools): + """Non-callable string tools should be available as variables.""" + repl = LocalREPL(custom_tools=custom_tools) + + result = repl.execute_code("result = CONSTANT.upper()") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == "HELLO WORLD" + + repl.cleanup() + + def test_tools_persist_across_executions(self, custom_tools): + """Custom tools should remain available across multiple executions.""" + repl = LocalREPL(custom_tools=custom_tools) + + repl.execute_code("x = double(5)") + result = repl.execute_code("y = double(x)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("y") == 20 + + repl.cleanup() + + def test_reserved_name_raises_on_init(self): + """Using a reserved name should raise ValueError during initialization.""" + with pytest.raises(ValueError) as exc_info: + LocalREPL(custom_tools={"llm_query": lambda: None}) + assert "llm_query" in str(exc_info.value) + + def test_empty_custom_tools(self): + """Empty custom_tools dict should work fine.""" + repl = LocalREPL(custom_tools={}) + + result = repl.execute_code("x = 1 + 1") + assert result.stderr == "" + assert repl.locals.get("x") == 2 + + repl.cleanup() + + def test_none_custom_tools(self): + """None custom_tools should work fine (default behavior).""" + repl = LocalREPL(custom_tools=None) + + result = repl.execute_code("x = 1 + 1") + assert result.stderr == "" + assert repl.locals.get("x") == 2 + + repl.cleanup() + + def test_builtin_functions_still_work(self, custom_tools): + """Built-in REPL functions should still work with custom tools.""" + repl = LocalREPL(custom_tools=custom_tools) + + # FINAL_VAR should still work + repl.execute_code("answer = double(21)") + result = repl.execute_code('print(FINAL_VAR("answer"))') + assert "42" in result.stdout + + # SHOW_VARS should still work + result = repl.execute_code("print(SHOW_VARS())") + assert "answer" in result.stdout + + repl.cleanup() + + +class TestCustomToolsWithContext: + """Tests for custom tools interacting with context.""" + + def test_tools_can_access_context(self): + """Custom tools should be able to work with context data.""" + + def process_context(ctx): + return f"Processed: {ctx['name']}" + + repl = LocalREPL( + custom_tools={"process_context": process_context}, + context_payload={"name": "test_data"}, + ) + + result = repl.execute_code("result = process_context(context)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == "Processed: test_data" + + repl.cleanup() + + def test_tools_with_string_context(self): + """Custom tools should work with string context.""" + + def count_words(text): + return len(text.split()) + + repl = LocalREPL( + custom_tools={"count_words": count_words}, + context_payload="hello world from test", + ) + + result = repl.execute_code("result = count_words(context)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == 4 + + repl.cleanup() + + +class TestCustomToolsEdgeCases: + """Edge case tests for custom tools.""" + + def test_tool_that_raises_exception(self): + """Tools that raise exceptions should propagate errors properly.""" + + def failing_tool(): + raise ValueError("Tool failed!") + + repl = LocalREPL(custom_tools={"failing_tool": failing_tool}) + + result = repl.execute_code("result = failing_tool()") + assert "ValueError" in result.stderr + assert "Tool failed!" in result.stderr + + repl.cleanup() + + def test_tool_with_closure(self): + """Tools with closures should work correctly.""" + multiplier = 10 + + def multiply(x): + return x * multiplier + + repl = LocalREPL(custom_tools={"multiply": multiply}) + + result = repl.execute_code("result = multiply(5)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == 50 + + repl.cleanup() + + def test_tool_returning_none(self): + """Tools returning None should work correctly.""" + + def returns_none(): + return None + + repl = LocalREPL(custom_tools={"returns_none": returns_none}) + + result = repl.execute_code("result = returns_none()") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") is None + + repl.cleanup() + + def test_lambda_tool(self): + """Lambda functions should work as tools.""" + repl = LocalREPL(custom_tools={"square": lambda x: x**2}) + + result = repl.execute_code("result = square(7)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == 49 + + repl.cleanup() + + def test_class_instance_as_tool(self): + """Class instances with __call__ should work as tools.""" + + class Adder: + def __init__(self, n): + self.n = n + + def __call__(self, x): + return x + self.n + + repl = LocalREPL(custom_tools={"add_five": Adder(5)}) + + result = repl.execute_code("result = add_five(10)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == 15 + + repl.cleanup() + + def test_tool_name_shadowing_import(self): + """Custom tools shouldn't prevent importing modules with same name.""" + repl = LocalREPL(custom_tools={"json": "not the real json"}) + + # The custom tool should be a string variable + result = repl.execute_code("result = json") + assert repl.locals.get("result") == "not the real json" + + # But we should still be able to import the real json module + result = repl.execute_code( + "import json as json_module; result2 = json_module.dumps({'a': 1})" + ) + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result2") == '{"a": 1}' + + repl.cleanup() + + +# ============================================================================= +# Tool Description Tests +# ============================================================================= + + +class TestParseToolEntry: + """Tests for parse_tool_entry function.""" + + def test_plain_callable(self): + """Plain callable should have no description.""" + + def func(x): + return x * 2 + + info = parse_tool_entry("my_func", func) + + assert info.name == "my_func" + assert info.value is func + assert info.description is None + assert info.is_callable is True + + def test_plain_value(self): + """Plain value should have no description.""" + info = parse_tool_entry("config", ["a", "b", "c"]) + + assert info.name == "config" + assert info.value == ["a", "b", "c"] + assert info.description is None + assert info.is_callable is False + + def test_dict_with_description(self): + """Dict with 'tool' and 'description' keys should extract both.""" + + def func(x): + return x * 2 + + info = parse_tool_entry("my_func", {"tool": func, "description": "Doubles the input"}) + + assert info.name == "my_func" + assert info.value is func + assert info.description == "Doubles the input" + assert info.is_callable is True + + def test_dict_value_with_description(self): + """Non-callable with description.""" + info = parse_tool_entry( + "config", {"tool": {"key": "value"}, "description": "Configuration settings"} + ) + + assert info.name == "config" + assert info.value == {"key": "value"} + assert info.description == "Configuration settings" + assert info.is_callable is False + + def test_dict_with_tool_but_no_description(self): + """Dict with 'tool' key but no description.""" + + def func(x): + return x + + info = parse_tool_entry("my_func", {"tool": func}) + + assert info.name == "my_func" + assert info.value is func + assert info.description is None + + def test_plain_dict_without_tool_key(self): + """Plain dict without 'tool' key should be treated as data value.""" + info = parse_tool_entry("config", {"key": "value", "other": 123}) + + assert info.name == "config" + assert info.value == {"key": "value", "other": 123} + assert info.description is None + assert info.is_callable is False + + +class TestExtractToolValue: + """Tests for extract_tool_value function.""" + + def test_plain_value(self): + """Plain value should be returned as-is.""" + assert extract_tool_value(42) == 42 + assert extract_tool_value("hello") == "hello" + + def test_plain_callable(self): + """Plain callable should be returned as-is.""" + + def func(x): + return x + + assert extract_tool_value(func) is func + + def test_dict_with_tool_key(self): + """Dict with 'tool' key should return just the tool value.""" + + def func(x): + return x + + assert extract_tool_value({"tool": func, "description": "desc"}) is func + assert extract_tool_value({"tool": {"key": "val"}, "description": "config"}) == { + "key": "val" + } + + def test_plain_dict_without_tool_key(self): + """Dict without 'tool' key should return whole dict.""" + assert extract_tool_value({"key": "val"}) == {"key": "val"} + assert extract_tool_value({"a": 1, "b": 2}) == {"a": 1, "b": 2} + + +class TestParseCustomTools: + """Tests for parse_custom_tools function.""" + + def test_none_returns_empty_list(self): + """None should return empty list.""" + assert parse_custom_tools(None) == [] + + def test_empty_dict_returns_empty_list(self): + """Empty dict should return empty list.""" + assert parse_custom_tools({}) == [] + + def test_mixed_tools(self): + """Mix of plain and described tools.""" + + def func1(x): + return x + + def func2(x): + return x * 2 + + tools = { + "plain_func": func1, + "described_func": {"tool": func2, "description": "Doubles input"}, + "plain_data": [1, 2, 3], + "described_data": {"tool": [1, 2, 3], "description": "A list of numbers"}, + } + + infos = parse_custom_tools(tools) + assert len(infos) == 4 + + # Find each by name + by_name = {info.name: info for info in infos} + + assert by_name["plain_func"].description is None + assert by_name["described_func"].description == "Doubles input" + assert by_name["plain_data"].description is None + assert by_name["described_data"].description == "A list of numbers" + + +class TestFormatToolsForPrompt: + """Tests for format_tools_for_prompt function.""" + + def test_none_returns_none(self): + """None tools should return None.""" + assert format_tools_for_prompt(None) is None + + def test_empty_returns_none(self): + """Empty tools should return None.""" + assert format_tools_for_prompt({}) is None + + def test_callable_with_description(self): + """Callable with description should show description.""" + tools = {"my_func": {"tool": lambda x: x, "description": "Does something useful"}} + result = format_tools_for_prompt(tools) + + assert "my_func" in result + assert "Does something useful" in result + + def test_callable_without_description(self): + """Callable without description should show generic message.""" + tools = {"my_func": lambda x: x} + result = format_tools_for_prompt(tools) + + assert "my_func" in result + assert "custom function" in result.lower() + + def test_value_with_description(self): + """Value with description should show description.""" + tools = {"config": {"tool": ["a", "b", "c"], "description": "Configuration settings"}} + result = format_tools_for_prompt(tools) + + assert "config" in result + assert "Configuration settings" in result + + def test_value_without_description(self): + """Value without description should show type.""" + tools = {"config": ["a", "b", "c"]} + result = format_tools_for_prompt(tools) + + assert "config" in result + assert "list" in result.lower() + + def test_multiple_tools_formatted(self): + """Multiple tools should all appear in output.""" + tools = { + "func1": {"tool": lambda x: x, "description": "First function"}, + "func2": lambda x: x, + "data1": {"tool": [1, 2, 3], "description": "Some data"}, + "data2": "plain string", + } + result = format_tools_for_prompt(tools) + + assert "func1" in result + assert "First function" in result + assert "func2" in result + assert "data1" in result + assert "Some data" in result + assert "data2" in result + + +class TestToolsWithDescriptionsInREPL: + """Tests for tools with descriptions in LocalREPL.""" + + def test_callable_with_description_works(self): + """Callable tool with description should work in REPL.""" + repl = LocalREPL( + custom_tools={ + "double": {"tool": lambda x: x * 2, "description": "Doubles the input value"}, + } + ) + + result = repl.execute_code("result = double(21)") + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == 42 + + repl.cleanup() + + def test_value_with_description_works(self): + """Value tool with description should work in REPL.""" + repl = LocalREPL( + custom_tools={ + "CONFIG": {"tool": {"api_key": "test123"}, "description": "API configuration"}, + } + ) + + result = repl.execute_code('result = CONFIG["api_key"]') + assert result.stderr == "", f"Unexpected error: {result.stderr}" + assert repl.locals.get("result") == "test123" + + repl.cleanup() + + def test_mixed_tools_with_descriptions(self): + """Mix of described and undescribed tools should work.""" + repl = LocalREPL( + custom_tools={ + "described_func": {"tool": lambda x: x + 1, "description": "Adds one"}, + "plain_func": lambda x: x - 1, + "described_data": {"tool": 100, "description": "A constant"}, + "plain_data": 200, + } + ) + + repl.execute_code("a = described_func(5)") + repl.execute_code("b = plain_func(5)") + repl.execute_code("c = described_data") + repl.execute_code("d = plain_data") + + assert repl.locals.get("a") == 6 + assert repl.locals.get("b") == 4 + assert repl.locals.get("c") == 100 + assert repl.locals.get("d") == 200 + + repl.cleanup() + + def test_described_tools_validate_reserved_names(self): + """Described tools should still validate against reserved names.""" + with pytest.raises(ValueError) as exc_info: + LocalREPL( + custom_tools={ + "llm_query": {"tool": lambda x: x, "description": "Override attempt"}, + } + ) + assert "llm_query" in str(exc_info.value) diff --git a/uv.lock b/uv.lock index 653d14c3a..6abd703d0 100644 --- a/uv.lock +++ b/uv.lock @@ -197,6 +197,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" }, ] +[[package]] +name = "bracex" +version = "2.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" }, +] + [[package]] name = "cached-property" version = "2.0.1" @@ -583,6 +592,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "dockerfile-parse" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" }, +] + [[package]] name = "docstring-parser" version = "0.17.0" @@ -592,6 +610,41 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/55/e2/2537ebcff11c1ee1ff17d8d0b6f4db75873e3b0fb32c2d4a2ee31ecb310a/docstring_parser-0.17.0-py3-none-any.whl", hash = "sha256:cf2569abd23dce8099b300f9b4fa8191e9582dda731fd533daf54c4551658708", size = 36896, upload-time = "2025-07-21T07:35:00.684Z" }, ] +[[package]] +name = "e2b" +version = "2.13.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "dockerfile-parse" }, + { name = "httpcore" }, + { name = "httpx" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "python-dateutil" }, + { name = "rich" }, + { name = "typing-extensions" }, + { name = "wcmatch" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/d0/745fe80a0bcc3b61eb81ab4b7640a10245625dc71479ce7ce9da9d9cd896/e2b-2.13.2.tar.gz", hash = "sha256:c0e81a3920091874fdf73c0b8f376b28766212db9f1cea5d8bd56a2e95d2436c", size = 133429, upload-time = "2026-02-09T19:27:58.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/5b/f83b0397406bb07b9572fc32ecd98502b104a3cfaba85ba4536e77146ccd/e2b-2.13.2-py3-none-any.whl", hash = "sha256:d91d5293bc0dd1917c72a6e6b35e86513607be2666a14ae18c57b921e7864de4", size = 240668, upload-time = "2026-02-09T19:27:57.126Z" }, +] + +[[package]] +name = "e2b-code-interpreter" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "e2b" }, + { name = "httpx" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/1e/eb/db6e51edd9f3402fd68d026572579b9b1bd833b10d990376a1e4c05d5b8d/e2b_code_interpreter-2.4.1.tar.gz", hash = "sha256:4b15014ee0d0dfcdc3072e1f409cbb87ca48f48d53d75629b7257e5513b9e7dd", size = 10700, upload-time = "2025-11-26T18:12:38.086Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1b/e7/09b9106ead227f7be14bd97c3181391ee498bb38933b1a9c566b72c8567a/e2b_code_interpreter-2.4.1-py3-none-any.whl", hash = "sha256:15d35f025b4a15033e119f2e12e7ac65657ad2b5a013fa9149e74581fbee778a", size = 13719, upload-time = "2025-11-26T18:12:36.7Z" }, +] + [[package]] name = "environs" version = "14.5.0" @@ -1706,7 +1759,7 @@ wheels = [ ] [[package]] -name = "rlm" +name = "rlms" version = "0.1.0" source = { editable = "." } dependencies = [ @@ -1725,6 +1778,10 @@ daytona = [ { name = "daytona" }, { name = "dill" }, ] +e2b = [ + { name = "dill" }, + { name = "e2b-code-interpreter" }, +] modal = [ { name = "dill" }, { name = "modal" }, @@ -1751,8 +1808,10 @@ requires-dist = [ { name = "anthropic", specifier = ">=0.75.0" }, { name = "daytona", marker = "extra == 'daytona'", specifier = ">=0.128.1" }, { name = "dill", marker = "extra == 'daytona'", specifier = ">=0.3.7" }, + { name = "dill", marker = "extra == 'e2b'", specifier = ">=0.3.7" }, { name = "dill", marker = "extra == 'modal'", specifier = ">=0.3.7" }, { name = "dill", marker = "extra == 'prime'", specifier = ">=0.3.7" }, + { name = "e2b-code-interpreter", marker = "extra == 'e2b'", specifier = ">=0.0.11" }, { name = "google-genai", specifier = ">=1.56.0" }, { name = "modal", marker = "extra == 'modal'", specifier = ">=0.73.0" }, { name = "openai", specifier = ">=2.14.0" }, @@ -1763,7 +1822,7 @@ requires-dist = [ { name = "requests", specifier = ">=2.32.5" }, { name = "rich", specifier = ">=13.0.0" }, ] -provides-extras = ["modal", "daytona", "prime"] +provides-extras = ["modal", "e2b", "daytona", "prime"] [package.metadata.requires-dev] dev = [ @@ -2134,6 +2193,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/d4/ed38dd3b1767193de971e694aa544356e63353c33a85d948166b5ff58b9e/watchfiles-1.1.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3e6f39af2eab0118338902798b5aa6664f46ff66bc0280de76fca67a7f262a49", size = 457546, upload-time = "2025-10-14T15:06:13.372Z" }, ] +[[package]] +name = "wcmatch" +version = "10.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "bracex" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" }, +] + [[package]] name = "websockets" version = "15.0.1"