Skip to content

Commit 835618f

Browse files
[enhance] Shared Multi Runtimes Across Toolkits (#3551)
Co-authored-by: Wendong-Fan <[email protected]>
1 parent 6b58e8a commit 835618f

File tree

6 files changed

+575
-35
lines changed

6 files changed

+575
-35
lines changed
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2+
# Licensed under the Apache License, Version 2.0 (the "License");
3+
# you may not use this file except in compliance with the License.
4+
# You may obtain a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS,
10+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11+
# See the License for the specific language governing permissions and
12+
# limitations under the License.
13+
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14+
15+
# Multi-toolkit Docker image for CAMEL shared runtimes
16+
# Supports: BrowserToolkit, TerminalToolkit, CodeExecutionToolkit, and more
17+
#
18+
# Build (from repo root):
19+
# docker build -f camel/runtimes/Dockerfile.multi-toolkit \
20+
# -t camel-multi-toolkit:latest .
21+
#
22+
# Usage with DockerRuntime:
23+
# runtime = (
24+
# DockerRuntime("camel-multi-toolkit:latest")
25+
# .add(BrowserToolkit().get_tools(), "camel.toolkits.BrowserToolkit")
26+
# .add(TerminalToolkit().get_tools(), "camel.toolkits.TerminalToolkit")
27+
# .add(CodeExecutionToolkit().get_tools(), "camel.toolkits.CodeExecutionToolkit")
28+
# .build()
29+
# )
30+
31+
FROM python:3.10-slim
32+
33+
# install system dependencies for various toolkits
34+
RUN apt-get update && apt-get install -y --no-install-recommends \
35+
# build tools (required for psutil, etc.)
36+
gcc \
37+
python3-dev \
38+
# common tools
39+
curl \
40+
wget \
41+
git \
42+
bash \
43+
# for Playwright browsers
44+
libnss3 \
45+
libnspr4 \
46+
libatk1.0-0 \
47+
libatk-bridge2.0-0 \
48+
libcups2 \
49+
libdrm2 \
50+
libxkbcommon0 \
51+
libxcomposite1 \
52+
libxdamage1 \
53+
libxfixes3 \
54+
libxrandr2 \
55+
libgbm1 \
56+
libasound2 \
57+
libpango-1.0-0 \
58+
libcairo2 \
59+
# additional dependencies
60+
libdbus-1-3 \
61+
libexpat1 \
62+
libfontconfig1 \
63+
libgcc-s1 \
64+
libglib2.0-0 \
65+
libgtk-3-0 \
66+
libx11-6 \
67+
libx11-xcb1 \
68+
libxcb1 \
69+
libxext6 \
70+
&& rm -rf /var/lib/apt/lists/*
71+
72+
# copy local CAMEL source and install from source
73+
# this ensures we use local changes rather than PyPI version
74+
COPY . /app/camel
75+
WORKDIR /app/camel
76+
RUN pip install --no-cache-dir -e '.[all]'
77+
78+
# install Playwright and chromium browser
79+
RUN pip install --no-cache-dir playwright \
80+
&& playwright install chromium \
81+
&& playwright install-deps chromium
82+
83+
# set working directory for runtime operations
84+
WORKDIR /workspace
85+
86+
# expose API port
87+
EXPOSE 8000
88+
89+
# default command is sleep infinity (DockerRuntime will exec into it)
90+
CMD ["sleep", "infinity"]

camel/runtimes/api.py

Lines changed: 77 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,22 +11,31 @@
1111
# See the License for the specific language governing permissions and
1212
# limitations under the License.
1313
# ========= Copyright 2023-2025 @ CAMEL-AI.org. All Rights Reserved. =========
14+
import asyncio
15+
import concurrent.futures
1416
import importlib
1517
import io
1618
import json
1719
import logging
1820
import os
1921
import sys
20-
from typing import Dict
22+
from typing import Any, Dict, List
2123

2224
import uvicorn
2325
from fastapi import FastAPI, Request
2426
from fastapi.responses import JSONResponse
2527

2628
from camel.toolkits import BaseToolkit
2729

30+
# thread pool for running sync tools that can't run inside async event loop
31+
# (e.g., Playwright sync API)
32+
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
33+
2834
logger = logging.getLogger(__name__)
2935

36+
# set environment variable to indicate we're running inside a CAMEL runtime
37+
os.environ["CAMEL_RUNTIME"] = "true"
38+
3039
sys.path.append(os.getcwd())
3140

3241
modules_functions = sys.argv[1:]
@@ -35,6 +44,22 @@
3544

3645
app = FastAPI()
3746

47+
# global cache for toolkit instances to maintain state across calls
48+
_toolkit_instances: Dict[str, Any] = {}
49+
50+
# track registered endpoints for health check
51+
_registered_endpoints: List[str] = []
52+
53+
54+
@app.get("/health")
55+
async def health_check():
56+
r"""Health check endpoint that reports loaded toolkits and endpoints."""
57+
return {
58+
"status": "ok",
59+
"toolkits": list(_toolkit_instances.keys()),
60+
"endpoints": _registered_endpoints,
61+
}
62+
3863

3964
@app.exception_handler(Exception)
4065
async def general_exception_handler(request: Request, exc: Exception):
@@ -49,6 +74,9 @@ async def general_exception_handler(request: Request, exc: Exception):
4974

5075
for module_function in modules_functions:
5176
try:
77+
# store original module_function as cache key before parsing
78+
cache_key = module_function
79+
5280
init_params = dict()
5381
if "{" in module_function:
5482
module_function, params = module_function.split("{")
@@ -62,36 +90,64 @@ async def general_exception_handler(request: Request, exc: Exception):
6290
module = importlib.import_module(module_name)
6391
function = getattr(module, function_name)
6492
if isinstance(function, type) and issubclass(function, BaseToolkit):
65-
function = function(**init_params).get_tools()
93+
# use cached instance if available to maintain state across calls
94+
if cache_key not in _toolkit_instances:
95+
_toolkit_instances[cache_key] = function(**init_params)
96+
function = _toolkit_instances[cache_key].get_tools()
6697

6798
if not isinstance(function, list):
6899
function = [function]
69100

70101
for func in function:
71-
72-
@app.post(f"/{func.get_function_name()}")
73-
async def dynamic_function(data: Dict, func=func):
74-
redirect_stdout = data.get('redirect_stdout', False)
75-
if redirect_stdout:
76-
sys.stdout = io.StringIO()
77-
response_data = func.func(*data['args'], **data['kwargs'])
78-
if redirect_stdout:
79-
sys.stdout.seek(0)
80-
output = sys.stdout.read()
81-
sys.stdout = sys.__stdout__
102+
endpoint_name = func.get_function_name()
103+
_registered_endpoints.append(endpoint_name)
104+
105+
def make_endpoint(tool):
106+
r"""Create endpoint with tool captured in closure."""
107+
108+
def run_tool(data: Dict):
109+
r"""Run tool in thread pool to avoid async event loop."""
110+
redirect_stdout = data.get('redirect_stdout', False)
111+
captured_output = None
112+
if redirect_stdout:
113+
captured_output = io.StringIO()
114+
old_stdout = sys.stdout
115+
sys.stdout = captured_output
116+
try:
117+
response_data = tool.func(
118+
*data['args'], **data['kwargs']
119+
)
120+
finally:
121+
if redirect_stdout:
122+
sys.stdout = old_stdout
123+
if redirect_stdout and captured_output is not None:
124+
captured_output.seek(0)
125+
output = captured_output.read()
126+
return {
127+
"output": json.dumps(
128+
response_data, ensure_ascii=False
129+
),
130+
"stdout": output,
131+
}
82132
return {
83-
"output": json.dumps(
84-
response_data, ensure_ascii=False
85-
),
86-
"stdout": output,
133+
"output": json.dumps(response_data, ensure_ascii=False)
87134
}
88-
return {
89-
"output": json.dumps(response_data, ensure_ascii=False)
90-
}
135+
136+
async def endpoint(data: Dict):
137+
# run in thread pool to support sync tools like Playwright
138+
loop = asyncio.get_running_loop()
139+
return await loop.run_in_executor(
140+
_executor, run_tool, data
141+
)
142+
143+
return endpoint
144+
145+
app.post(f"/{endpoint_name}")(make_endpoint(func))
91146

92147
except (ImportError, AttributeError) as e:
93148
logger.error(f"Error importing {module_function}: {e}")
94149

95150

96151
if __name__ == "__main__":
97-
uvicorn.run("__main__:app", host="0.0.0.0", port=8000, reload=True)
152+
# reload=False to avoid conflicts with async toolkits (e.g., Playwright)
153+
uvicorn.run(app, host="0.0.0.0", port=8000, reload=False)

camel/toolkits/browser_toolkit.py

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,12 @@ def __init__(
147147
Returns:
148148
None
149149
"""
150-
from playwright.sync_api import (
151-
sync_playwright,
152-
)
153-
154150
self.history: List[Any] = []
155151
self.headless = headless
156152
self.channel = channel
157153
self._ensure_browser_installed()
158-
self.playwright: Playwright = sync_playwright().start()
154+
# lazy initialization - playwright is started in init() method
155+
self.playwright: Optional[Playwright] = None
159156
self.page_history: List[
160157
str
161158
] = [] # stores the history of visited pages
@@ -192,7 +189,11 @@ def __init__(
192189

193190
def init(self) -> None:
194191
r"""Initialize the browser."""
195-
assert self.playwright is not None
192+
# lazy start playwright when init() is called, not in __init__
193+
if self.playwright is None:
194+
from playwright.sync_api import sync_playwright
195+
196+
self.playwright = sync_playwright().start()
196197

197198
browser_launch_args = [
198199
"--disable-blink-features=AutomationControlled", # Basic stealth
@@ -677,7 +678,6 @@ def find_text_on_page(self, search_text: str) -> str:
677678
targeted text. It is equivalent to pressing Ctrl + F and searching for
678679
the text.
679680
"""
680-
# ruff: noqa: E501
681681
assert self.page is not None
682682
script = f"""
683683
(function() {{
@@ -737,7 +737,6 @@ def close(self):
737737
if self.playwright:
738738
self.playwright.stop() # Stop playwright instance
739739

740-
# ruff: noqa: E501
741740
def show_interactive_elements(self):
742741
r"""Show simple interactive elements on the current page."""
743742
assert self.page is not None
@@ -829,6 +828,9 @@ def __init__(
829828
830829
Args:
831830
headless (bool): Whether to run the browser in headless mode.
831+
When running inside a CAMEL runtime container, this is
832+
automatically set to True since containers typically don't
833+
have a display.
832834
cache_dir (Union[str, None]): The directory to store cache files.
833835
channel (Literal["chrome", "msedge", "chromium"]): The browser
834836
channel to use. Must be one of "chrome", "msedge", or
@@ -852,6 +854,17 @@ def __init__(
852854
is used without saving data. (default: :obj:`None`)
853855
"""
854856
super().__init__() # Call to super().__init__() added
857+
858+
# auto-detect if running inside a CAMEL runtime container
859+
# force headless mode since containers typically don't have a display
860+
in_runtime = os.environ.get("CAMEL_RUNTIME", "").lower() == "true"
861+
if in_runtime and not headless:
862+
logger.info(
863+
"Detected CAMEL_RUNTIME environment - enabling headless mode "
864+
"since containers typically don't have a display"
865+
)
866+
headless = True
867+
855868
self.browser = BaseBrowser(
856869
headless=headless,
857870
cache_dir=cache_dir,
@@ -890,17 +903,17 @@ def _initialize_agent(
890903

891904
if web_agent_model_backend is None:
892905
web_agent_model_instance = ModelFactory.create(
893-
model_platform=ModelPlatformType.OPENAI,
894-
model_type=ModelType.GPT_4_1,
906+
model_platform=ModelPlatformType.DEFAULT,
907+
model_type=ModelType.DEFAULT,
895908
model_config_dict={"temperature": 0, "top_p": 1},
896909
)
897910
else:
898911
web_agent_model_instance = web_agent_model_backend
899912

900913
if planning_agent_model_backend is None:
901914
planning_model = ModelFactory.create(
902-
model_platform=ModelPlatformType.OPENAI,
903-
model_type=ModelType.O3_MINI,
915+
model_platform=ModelPlatformType.DEFAULT,
916+
model_type=ModelType.DEFAULT,
904917
)
905918
else:
906919
planning_model = planning_agent_model_backend

camel/toolkits/terminal_toolkit/terminal_toolkit.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,18 @@ def __init__(
100100
clone_current_env: bool = False,
101101
install_dependencies: Optional[List[str]] = None,
102102
):
103+
# auto-detect if running inside a CAMEL runtime container
104+
# when inside a runtime, use local execution (already sandboxed)
105+
runtime_env = os.environ.get("CAMEL_RUNTIME", "").lower()
106+
self._in_runtime = runtime_env == "true"
107+
if self._in_runtime and use_docker_backend:
108+
logger.info(
109+
"Detected CAMEL_RUNTIME environment - disabling Docker "
110+
"backend since we're already inside a sandboxed container"
111+
)
112+
use_docker_backend = False
113+
docker_container_name = None
114+
103115
self.use_docker_backend = use_docker_backend
104116
self.timeout = timeout
105117
self.shell_sessions: Dict[str, Dict[str, Any]] = {}
@@ -219,8 +231,13 @@ def __init__(
219231
except APIError as e:
220232
raise RuntimeError(f"Failed to connect to Docker daemon: {e}")
221233

222-
# Set up environments (only for local backend)
223-
if not self.use_docker_backend:
234+
# Set up environments (only for local backend, skip in runtime mode)
235+
if self._in_runtime:
236+
logger.info(
237+
"[ENV] Skipping environment setup - running inside "
238+
"CAMEL runtime container"
239+
)
240+
elif not self.use_docker_backend:
224241
if self.clone_current_env:
225242
self._setup_cloned_environment()
226243
else:

0 commit comments

Comments
 (0)