Skip to content

Commit b35a881

Browse files
authored
Add custom tool call passing (#106)
* add custom tool calling * remove subcall tools for now, until we add in recursive depth >1 and better agents with their own REPLs
1 parent e59e9a3 commit b35a881

File tree

12 files changed

+1306
-18
lines changed

12 files changed

+1306
-18
lines changed

docs/getting-started.md

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ This will display:
138138
| `other_backend_kwargs` | `list` | `None` | Configs for additional backends |
139139
| `logger` | `RLMLogger` | `None` | Logger for trajectory tracking |
140140
| `verbose` | `bool` | `False` | Enable console output |
141+
| `custom_tools` | `dict` | `None` | Custom functions/data available in REPL |
141142

142143
### The `completion()` Method
143144

@@ -285,6 +286,90 @@ rlm = RLM(
285286

286287
---
287288

289+
## Custom Tools
290+
291+
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.
292+
293+
### Basic Usage
294+
295+
```python
296+
def fetch_weather(city: str) -> str:
297+
"""Fetch weather data for a city."""
298+
# Your API call here
299+
return f"Weather in {city}: Sunny, 72°F"
300+
301+
def calculate_shipping(weight: float, distance: float) -> float:
302+
"""Calculate shipping cost."""
303+
return weight * 0.5 + distance * 0.1
304+
305+
rlm = RLM(
306+
backend="openai",
307+
backend_kwargs={"model_name": "gpt-4o"},
308+
custom_tools={
309+
"fetch_weather": fetch_weather,
310+
"calculate_shipping": calculate_shipping,
311+
"API_KEY": "your-api-key", # Non-callable values become variables
312+
},
313+
)
314+
315+
result = rlm.completion("What's the weather in Tokyo and calculate shipping for 10kg over 500km?")
316+
```
317+
318+
Inside the REPL, the model can now call:
319+
```python
320+
weather = fetch_weather("Tokyo")
321+
cost = calculate_shipping(10, 500)
322+
```
323+
324+
### Tool Descriptions
325+
326+
You can provide descriptions for your tools that will be included in the system prompt, helping the model understand what each tool does:
327+
328+
```python
329+
rlm = RLM(
330+
backend="openai",
331+
backend_kwargs={"model_name": "gpt-4o"},
332+
custom_tools={
333+
# Dict format: {"tool": callable_or_value, "description": "..."}
334+
"fetch_weather": {"tool": fetch_weather, "description": "Fetch current weather data for a city name"},
335+
"calculate_shipping": {"tool": calculate_shipping, "description": "Calculate shipping cost given weight (kg) and distance (km)"},
336+
"API_KEY": {"tool": "your-api-key", "description": "API key for the weather service"},
337+
},
338+
)
339+
```
340+
341+
The descriptions are automatically added to the system prompt:
342+
```
343+
6. Custom tools and data available in the REPL:
344+
- `fetch_weather`: Fetch current weather data for a city name
345+
- `calculate_shipping`: Calculate shipping cost given weight (kg) and distance (km)
346+
- `API_KEY`: API key for the weather service
347+
```
348+
349+
### Isolated Environments (Modal, Daytona)
350+
351+
For isolated environments, custom tools must be serializable. You can provide:
352+
353+
1. **Code strings** - Python code that defines the function:
354+
```python
355+
custom_tools = {
356+
"helper": '''
357+
def helper(x):
358+
return x * 2
359+
''',
360+
}
361+
```
362+
363+
2. **Serializable data** - JSON-compatible values (strings, numbers, dicts, lists):
364+
```python
365+
custom_tools = {
366+
"CONFIG": {"api_url": "https://api.example.com", "timeout": 30},
367+
"ALLOWED_CITIES": ["Tokyo", "London", "New York"],
368+
}
369+
```
370+
371+
---
372+
288373
## Logging and Debugging
289374

290375
### Enable Logging

docs/src/app/environments/page.tsx

Lines changed: 51 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,16 +99,20 @@ export default function EnvironmentsPage() {
9999
],
100100
[
101101
<code key="2" className="text-sm font-semibold">llm_query(prompt, model=None)</code>,
102-
"Query a sub-LM from within the REPL. Returns the completion string."
102+
"Single LM completion call. Returns the completion string. Does not have tool access."
103103
],
104104
[
105105
<code key="3" className="text-sm font-semibold">llm_query_batched(prompts, model=None)</code>,
106-
"Concurrent sub-LM queries. Returns a list of completion strings."
106+
"Concurrent single LM completion calls. Returns a list of completion strings. Does not have tool access."
107107
],
108108
[
109109
<code key="4" className="text-sm font-semibold">FINAL_VAR(var_name)</code>,
110110
"Mark a variable as the final answer to return from the RLM"
111111
],
112+
[
113+
<code key="5" className="text-sm font-semibold">custom_tools</code>,
114+
"Any custom functions or data you provide via the custom_tools parameter"
115+
],
112116
]}
113117
/>
114118
</div>
@@ -119,13 +123,58 @@ context = "Your input here"
119123
# Query a sub-LM
120124
result = llm_query("Summarize the context", model="gpt-5-mini")
121125
126+
# Use a custom tool (if provided)
127+
data = fetch_data(context["url"]) # Custom function
128+
122129
# Process the result
123130
summary = process(result)
124131
125132
# Return final answer
126133
FINAL_VAR(summary)`} />
127134
</div>
128135

136+
<div className="my-12">
137+
<h2 className="text-2xl font-bold mb-4">Custom Tools</h2>
138+
139+
<p className="text-muted-foreground mb-6 leading-relaxed">
140+
You can provide custom functions and data that the RLM can use in its REPL environment
141+
via the <code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-sm font-semibold">custom_tools</code> parameter:
142+
</p>
143+
144+
<CodeBlock code={`from rlm import RLM
145+
146+
def fetch_weather(city: str) -> str:
147+
"""Fetch weather data for a city."""
148+
return f"Weather in {city}: Sunny, 72°F"
149+
150+
rlm = RLM(
151+
backend="openai",
152+
backend_kwargs={"model_name": "gpt-4o"},
153+
custom_tools={
154+
# Plain format (no description)
155+
"fetch_weather": fetch_weather,
156+
157+
# Dict format with description (recommended)
158+
"calculate_tip": {"tool": lambda x: x * 0.2, "description": "Calculate 20% tip for a bill amount"},
159+
"API_KEY": {"tool": "your-key", "description": "API key for external services"},
160+
},
161+
)
162+
163+
# The model can now call fetch_weather() in its REPL code`} />
164+
165+
<p className="text-muted-foreground mt-6 leading-relaxed">
166+
<strong className="text-foreground">Tool descriptions:</strong> Use the dict format
167+
<code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-sm font-semibold">{`{"tool": value, "description": "..."}`}</code> to
168+
provide descriptions that help the model understand what each tool does. Descriptions are automatically
169+
included in the system prompt.
170+
</p>
171+
172+
<p className="text-muted-foreground mt-4 leading-relaxed">
173+
<strong className="text-foreground">Note:</strong> <code className="px-1.5 py-0.5 rounded bg-muted text-foreground text-sm font-semibold">llm_query()</code> calls
174+
are single LM completions and do not have access to custom tools. Only the main RLM execution context has tool access.
175+
</p>
176+
</div>
177+
129178
<div className="my-12">
130179
<h2 className="text-2xl font-bold mb-4">Architecture</h2>
131180

examples/custom_tools_example.py

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
"""
2+
Example demonstrating custom tools in RLM.
3+
4+
Custom tools allow you to provide domain-specific functions and data
5+
that the RLM can use in its REPL environment.
6+
7+
Run with: python -m examples.custom_tools_example
8+
"""
9+
10+
import os
11+
from typing import Any
12+
13+
from dotenv import load_dotenv
14+
15+
from rlm import RLM
16+
17+
load_dotenv()
18+
19+
20+
# =============================================================================
21+
# Define Custom Tools
22+
# =============================================================================
23+
24+
25+
def fetch_stock_price(symbol: str) -> dict[str, Any]:
26+
"""
27+
Fetch stock price data for a given symbol.
28+
In a real application, this would call a financial API.
29+
"""
30+
# Mock data for demonstration
31+
prices = {
32+
"AAPL": {"price": 178.50, "change": 2.3, "volume": 52_000_000},
33+
"GOOGL": {"price": 141.25, "change": -0.8, "volume": 21_000_000},
34+
"MSFT": {"price": 378.90, "change": 1.5, "volume": 18_000_000},
35+
"AMZN": {"price": 178.75, "change": 0.2, "volume": 35_000_000},
36+
}
37+
return prices.get(symbol.upper(), {"error": f"Symbol {symbol} not found"})
38+
39+
40+
def calculate_portfolio_value(holdings: dict[str, int]) -> float:
41+
"""
42+
Calculate total portfolio value given holdings.
43+
holdings: dict mapping symbol to number of shares
44+
"""
45+
total = 0.0
46+
for symbol, shares in holdings.items():
47+
data = fetch_stock_price(symbol)
48+
if "price" in data:
49+
total += data["price"] * shares
50+
return total
51+
52+
53+
def format_currency(amount: float) -> str:
54+
"""Format a number as USD currency."""
55+
return f"${amount:,.2f}"
56+
57+
58+
# Configuration data (non-callable values become variables)
59+
MARKET_CONFIG = {
60+
"trading_hours": {"open": "09:30", "close": "16:00"},
61+
"currency": "USD",
62+
"exchange": "NYSE",
63+
}
64+
65+
66+
# =============================================================================
67+
# Example 1: Basic Custom Tools
68+
# =============================================================================
69+
70+
71+
def example_basic_tools():
72+
"""Demonstrate basic custom tools usage with descriptions."""
73+
print("=" * 60)
74+
print("Example 1: Basic Custom Tools with Descriptions")
75+
print("=" * 60)
76+
77+
# Tools can be provided with descriptions using dict format:
78+
# {"name": {"tool": callable_or_value, "description": "..."}}
79+
# The description will be included in the system prompt so the model
80+
# knows what each tool does.
81+
82+
rlm = RLM(
83+
backend="portkey",
84+
backend_kwargs={
85+
"model_name": "@openai/gpt-5-nano",
86+
"api_key": os.getenv("PORTKEY_API_KEY"),
87+
},
88+
environment="local",
89+
custom_tools={
90+
# Callable functions with descriptions (dict format)
91+
"fetch_stock_price": {
92+
"tool": fetch_stock_price,
93+
"description": "Fetch current stock price data for a symbol (AAPL, GOOGL, MSFT, AMZN)",
94+
},
95+
"calculate_portfolio_value": {
96+
"tool": calculate_portfolio_value,
97+
"description": "Calculate total portfolio value from a dict of {symbol: shares}",
98+
},
99+
"format_currency": {
100+
"tool": format_currency,
101+
"description": "Format a number as USD currency string",
102+
},
103+
# Data values with descriptions
104+
"MARKET_CONFIG": {
105+
"tool": MARKET_CONFIG,
106+
"description": "Market configuration including trading hours and exchange info",
107+
},
108+
},
109+
verbose=True,
110+
)
111+
112+
# The model can now use these tools to answer questions
113+
result = rlm.completion(
114+
"What's the current price of AAPL and GOOGL? "
115+
"Calculate the total value of a portfolio with 100 shares of each. "
116+
"Format the result as currency."
117+
)
118+
119+
print(f"\nFinal Answer: {result.response}")
120+
121+
122+
# =============================================================================
123+
# Main
124+
# =============================================================================
125+
126+
127+
if __name__ == "__main__":
128+
if not os.getenv("OPENAI_API_KEY"):
129+
print("Please set OPENAI_API_KEY environment variable")
130+
print("You can also modify this example to use a different backend")
131+
exit(1)
132+
133+
example_basic_tools()

examples/quickstart.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,10 @@
1010
logger = RLMLogger(log_dir="./logs")
1111

1212
rlm = RLM(
13-
backend="openai", # or "portkey", etc.
13+
backend="portkey", # or "openai", etc.
1414
backend_kwargs={
15-
"model_name": "gpt-5-nano",
16-
"api_key": os.getenv("OPENAI_API_KEY"),
15+
"model_name": "@openai/gpt-5-nano",
16+
"api_key": os.getenv("PORTKEY_API_KEY"),
1717
},
1818
environment="local",
1919
environment_kwargs={},

rlm/core/rlm.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ def __init__(
5252
logger: RLMLogger | None = None,
5353
verbose: bool = False,
5454
persistent: bool = False,
55+
custom_tools: dict[str, Any] | None = None,
56+
custom_sub_tools: dict[str, Any] | None = None,
5557
):
5658
"""
5759
Args:
@@ -68,6 +70,10 @@ def __init__(
6870
logger: The logger to use for the RLM.
6971
verbose: Whether to print verbose output in rich to console.
7072
persistent: If True, reuse the environment across completion() calls for multi-turn conversations.
73+
custom_tools: Dict of custom functions/tools available in the REPL. Keys are function names,
74+
values are callable functions. These are injected into the REPL globals.
75+
custom_sub_tools: Dict of custom tools for sub-agents (llm_query calls). If None, inherits
76+
from custom_tools. Pass an empty dict {} to disable tools for sub-agents.
7177
"""
7278
# Store config for spawning per-completion
7379
self.backend = backend
@@ -87,6 +93,11 @@ def __init__(
8793
self.other_backends = other_backends
8894
self.other_backend_kwargs = other_backend_kwargs
8995

96+
# Custom tools: functions available in the REPL environment
97+
self.custom_tools = custom_tools
98+
# Sub-tools: if None, inherit from custom_tools; if {}, no tools for sub-agents
99+
self.custom_sub_tools = custom_sub_tools if custom_sub_tools is not None else custom_tools
100+
90101
self.depth = depth
91102
self.max_depth = max_depth
92103
self.max_iterations = max_iterations
@@ -165,6 +176,11 @@ def _spawn_completion_context(self, prompt: str | dict[str, Any]):
165176
env_kwargs["lm_handler_address"] = (lm_handler.host, lm_handler.port)
166177
env_kwargs["context_payload"] = prompt
167178
env_kwargs["depth"] = self.depth + 1 # Environment depth is RLM depth + 1
179+
# Pass custom tools to the environment
180+
if self.custom_tools is not None:
181+
env_kwargs["custom_tools"] = self.custom_tools
182+
if self.custom_sub_tools is not None:
183+
env_kwargs["custom_sub_tools"] = self.custom_sub_tools
168184
environment: BaseEnv = get_environment(self.environment_type, env_kwargs)
169185

170186
if self.persistent:
@@ -184,7 +200,9 @@ def _setup_prompt(self, prompt: str | dict[str, Any]) -> list[dict[str, Any]]:
184200
"""
185201
metadata = QueryMetadata(prompt)
186202
message_history = build_rlm_system_prompt(
187-
system_prompt=self.system_prompt, query_metadata=metadata
203+
system_prompt=self.system_prompt,
204+
query_metadata=metadata,
205+
custom_tools=self.custom_tools,
188206
)
189207

190208
return message_history

0 commit comments

Comments
 (0)