camel-ai 0.2.75a6__py3-none-any.whl → 0.2.76__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of camel-ai might be problematic. Click here for more details.
- camel/__init__.py +1 -1
- camel/agents/chat_agent.py +1001 -205
- camel/agents/mcp_agent.py +30 -27
- camel/configs/__init__.py +6 -0
- camel/configs/amd_config.py +70 -0
- camel/configs/cometapi_config.py +104 -0
- camel/data_collectors/alpaca_collector.py +15 -6
- camel/environments/tic_tac_toe.py +1 -1
- camel/interpreters/__init__.py +2 -0
- camel/interpreters/docker/Dockerfile +3 -12
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/loaders/__init__.py +11 -2
- camel/loaders/chunkr_reader.py +9 -0
- camel/memories/__init__.py +2 -1
- camel/memories/agent_memories.py +3 -1
- camel/memories/blocks/chat_history_block.py +21 -3
- camel/memories/records.py +88 -8
- camel/messages/base.py +127 -34
- camel/models/__init__.py +4 -0
- camel/models/amd_model.py +101 -0
- camel/models/azure_openai_model.py +0 -6
- camel/models/base_model.py +30 -0
- camel/models/cometapi_model.py +83 -0
- camel/models/model_factory.py +4 -0
- camel/models/openai_compatible_model.py +0 -6
- camel/models/openai_model.py +0 -6
- camel/models/zhipuai_model.py +61 -2
- camel/parsers/__init__.py +18 -0
- camel/parsers/mcp_tool_call_parser.py +176 -0
- camel/retrievers/auto_retriever.py +1 -0
- camel/runtimes/daytona_runtime.py +11 -12
- camel/societies/workforce/prompts.py +131 -50
- camel/societies/workforce/single_agent_worker.py +434 -49
- camel/societies/workforce/structured_output_handler.py +30 -18
- camel/societies/workforce/task_channel.py +43 -0
- camel/societies/workforce/utils.py +105 -12
- camel/societies/workforce/workforce.py +1322 -311
- camel/societies/workforce/workforce_logger.py +24 -5
- camel/storages/key_value_storages/json.py +15 -2
- camel/storages/object_storages/google_cloud.py +1 -1
- camel/storages/vectordb_storages/oceanbase.py +10 -11
- camel/storages/vectordb_storages/tidb.py +8 -6
- camel/tasks/task.py +4 -3
- camel/toolkits/__init__.py +18 -5
- camel/toolkits/aci_toolkit.py +45 -0
- camel/toolkits/code_execution.py +28 -1
- camel/toolkits/context_summarizer_toolkit.py +684 -0
- camel/toolkits/dingtalk.py +1135 -0
- camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
- camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
- camel/toolkits/function_tool.py +6 -1
- camel/toolkits/google_drive_mcp_toolkit.py +12 -31
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +79 -2
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
- camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
- camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
- camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +405 -131
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
- camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
- camel/toolkits/markitdown_toolkit.py +27 -1
- camel/toolkits/mcp_toolkit.py +348 -348
- camel/toolkits/message_integration.py +3 -0
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/note_taking_toolkit.py +18 -8
- camel/toolkits/notion_mcp_toolkit.py +16 -26
- camel/toolkits/origene_mcp_toolkit.py +8 -49
- camel/toolkits/playwright_mcp_toolkit.py +12 -31
- camel/toolkits/resend_toolkit.py +168 -0
- camel/toolkits/slack_toolkit.py +50 -1
- camel/toolkits/terminal_toolkit/__init__.py +18 -0
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +924 -0
- camel/toolkits/terminal_toolkit/utils.py +532 -0
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +17 -11
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/types/enums.py +124 -1
- camel/types/unified_model_type.py +5 -0
- camel/utils/commons.py +17 -0
- camel/utils/context_utils.py +804 -0
- camel/utils/mcp.py +136 -2
- camel/utils/token_counting.py +25 -17
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/METADATA +158 -59
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/RECORD +95 -76
- camel/loaders/pandas_reader.py +0 -368
- camel/toolkits/terminal_toolkit.py +0 -1788
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/WHEEL +0 -0
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/licenses/LICENSE +0 -0
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
# ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
|
|
14
14
|
|
|
15
15
|
import asyncio
|
|
16
|
+
import contextlib
|
|
16
17
|
import datetime
|
|
17
18
|
import json
|
|
18
19
|
import os
|
|
@@ -33,9 +34,42 @@ else:
|
|
|
33
34
|
from camel.logger import get_logger
|
|
34
35
|
from camel.utils.tool_result import ToolResult
|
|
35
36
|
|
|
37
|
+
from .installer import check_and_install_dependencies
|
|
38
|
+
|
|
36
39
|
logger = get_logger(__name__)
|
|
37
40
|
|
|
38
41
|
|
|
42
|
+
def _create_memory_aware_error(base_msg: str) -> str:
|
|
43
|
+
import psutil
|
|
44
|
+
|
|
45
|
+
mem = psutil.virtual_memory()
|
|
46
|
+
if mem.available < 1024**3:
|
|
47
|
+
return (
|
|
48
|
+
f"{base_msg} "
|
|
49
|
+
f"(likely due to insufficient memory). "
|
|
50
|
+
f"Available memory: {mem.available / 1024**3:.2f}GB "
|
|
51
|
+
f"({mem.percent}% used)"
|
|
52
|
+
)
|
|
53
|
+
return base_msg
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def _cleanup_process_and_tasks(process, log_reader_task, ts_log_file):
|
|
57
|
+
if process:
|
|
58
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
59
|
+
process.kill()
|
|
60
|
+
with contextlib.suppress(Exception):
|
|
61
|
+
process.wait(timeout=2)
|
|
62
|
+
|
|
63
|
+
if log_reader_task and not log_reader_task.done():
|
|
64
|
+
log_reader_task.cancel()
|
|
65
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
66
|
+
await log_reader_task
|
|
67
|
+
|
|
68
|
+
if ts_log_file:
|
|
69
|
+
with contextlib.suppress(Exception):
|
|
70
|
+
ts_log_file.close()
|
|
71
|
+
|
|
72
|
+
|
|
39
73
|
def action_logger(func):
|
|
40
74
|
"""Decorator to add logging to action methods."""
|
|
41
75
|
|
|
@@ -44,23 +78,19 @@ def action_logger(func):
|
|
|
44
78
|
action_name = func.__name__
|
|
45
79
|
start_time = time.time()
|
|
46
80
|
|
|
47
|
-
# Log inputs (skip self)
|
|
48
81
|
inputs = {
|
|
49
82
|
"args": args,
|
|
50
83
|
"kwargs": kwargs,
|
|
51
84
|
}
|
|
52
85
|
|
|
53
86
|
try:
|
|
54
|
-
# Execute the original function
|
|
55
87
|
result = await func(self, *args, **kwargs)
|
|
56
88
|
execution_time = time.time() - start_time
|
|
57
89
|
|
|
58
|
-
# Extract page load time if available
|
|
59
90
|
page_load_time = None
|
|
60
91
|
if isinstance(result, dict) and 'page_load_time_ms' in result:
|
|
61
92
|
page_load_time = result['page_load_time_ms'] / 1000.0
|
|
62
93
|
|
|
63
|
-
# Log success
|
|
64
94
|
await self._log_action(
|
|
65
95
|
action_name=action_name,
|
|
66
96
|
inputs=inputs,
|
|
@@ -75,7 +105,6 @@ def action_logger(func):
|
|
|
75
105
|
execution_time = time.time() - start_time
|
|
76
106
|
error_msg = f"{type(e).__name__}: {e!s}"
|
|
77
107
|
|
|
78
|
-
# Log error
|
|
79
108
|
await self._log_action(
|
|
80
109
|
action_name=action_name,
|
|
81
110
|
inputs=inputs,
|
|
@@ -110,29 +139,35 @@ class WebSocketBrowserWrapper:
|
|
|
110
139
|
self.process: Optional[subprocess.Popen] = None
|
|
111
140
|
self.websocket = None
|
|
112
141
|
self.server_port = None
|
|
113
|
-
self._send_lock = asyncio.Lock()
|
|
114
|
-
self._receive_task = None
|
|
115
|
-
self._pending_responses: Dict[
|
|
116
|
-
|
|
117
|
-
|
|
142
|
+
self._send_lock = asyncio.Lock()
|
|
143
|
+
self._receive_task = None
|
|
144
|
+
self._pending_responses: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
|
|
145
|
+
self._browser_opened = False
|
|
146
|
+
self._server_ready_future = None
|
|
118
147
|
|
|
119
|
-
# Logging configuration
|
|
120
148
|
self.browser_log_to_file = (config or {}).get(
|
|
121
149
|
'browser_log_to_file', False
|
|
122
150
|
)
|
|
151
|
+
self.log_dir = (config or {}).get('log_dir', 'browser_log')
|
|
123
152
|
self.session_id = (config or {}).get('session_id', 'default')
|
|
124
153
|
self.log_file_path: Optional[str] = None
|
|
125
154
|
self.log_buffer: List[Dict[str, Any]] = []
|
|
155
|
+
self.ts_log_file_path: Optional[str] = None
|
|
156
|
+
self.ts_log_file = None
|
|
157
|
+
self._log_reader_task = None
|
|
126
158
|
|
|
127
|
-
# Set up log file if needed
|
|
128
159
|
if self.browser_log_to_file:
|
|
129
|
-
log_dir = "browser_log"
|
|
160
|
+
log_dir = self.log_dir if self.log_dir else "browser_log"
|
|
130
161
|
os.makedirs(log_dir, exist_ok=True)
|
|
131
162
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
132
163
|
self.log_file_path = os.path.join(
|
|
133
164
|
log_dir,
|
|
134
165
|
f"hybrid_browser_toolkit_ws_{timestamp}_{self.session_id}.log",
|
|
135
166
|
)
|
|
167
|
+
self.ts_log_file_path = os.path.join(
|
|
168
|
+
log_dir,
|
|
169
|
+
f"typescript_console_{timestamp}_{self.session_id}.log",
|
|
170
|
+
)
|
|
136
171
|
|
|
137
172
|
async def __aenter__(self):
|
|
138
173
|
"""Async context manager entry."""
|
|
@@ -143,155 +178,278 @@ class WebSocketBrowserWrapper:
|
|
|
143
178
|
"""Async context manager exit."""
|
|
144
179
|
await self.stop()
|
|
145
180
|
|
|
181
|
+
async def _cleanup_existing_processes(self):
|
|
182
|
+
"""Clean up any existing Node.js WebSocket server processes."""
|
|
183
|
+
import psutil
|
|
184
|
+
|
|
185
|
+
cleaned_count = 0
|
|
186
|
+
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
187
|
+
try:
|
|
188
|
+
if (
|
|
189
|
+
proc.info['name']
|
|
190
|
+
and 'node' in proc.info['name'].lower()
|
|
191
|
+
and proc.info['cmdline']
|
|
192
|
+
and any(
|
|
193
|
+
'websocket-server.js' in arg
|
|
194
|
+
for arg in proc.info['cmdline']
|
|
195
|
+
)
|
|
196
|
+
):
|
|
197
|
+
if any(self.ts_dir in arg for arg in proc.info['cmdline']):
|
|
198
|
+
logger.warning(
|
|
199
|
+
f"Found existing WebSocket server process "
|
|
200
|
+
f"(PID: {proc.info['pid']}). "
|
|
201
|
+
f"Terminating it to prevent conflicts."
|
|
202
|
+
)
|
|
203
|
+
proc.terminate()
|
|
204
|
+
try:
|
|
205
|
+
proc.wait(timeout=3)
|
|
206
|
+
except psutil.TimeoutExpired:
|
|
207
|
+
proc.kill()
|
|
208
|
+
cleaned_count += 1
|
|
209
|
+
except (
|
|
210
|
+
psutil.NoSuchProcess,
|
|
211
|
+
psutil.AccessDenied,
|
|
212
|
+
psutil.ZombieProcess,
|
|
213
|
+
):
|
|
214
|
+
pass
|
|
215
|
+
|
|
216
|
+
if cleaned_count > 0:
|
|
217
|
+
logger.warning(
|
|
218
|
+
f"Cleaned up {cleaned_count} existing WebSocket server "
|
|
219
|
+
f"process(es). This may have been caused by improper "
|
|
220
|
+
f"shutdown in previous sessions."
|
|
221
|
+
)
|
|
222
|
+
await asyncio.sleep(0.5)
|
|
223
|
+
|
|
146
224
|
async def start(self):
|
|
147
225
|
"""Start the WebSocket server and connect to it."""
|
|
148
|
-
|
|
149
|
-
npm_check = subprocess.run(
|
|
150
|
-
['npm', '--version'],
|
|
151
|
-
capture_output=True,
|
|
152
|
-
text=True,
|
|
153
|
-
)
|
|
154
|
-
if npm_check.returncode != 0:
|
|
155
|
-
raise RuntimeError(
|
|
156
|
-
"npm is not installed or not in PATH. "
|
|
157
|
-
"Please install Node.js and npm from https://nodejs.org/ "
|
|
158
|
-
"to use the hybrid browser toolkit."
|
|
159
|
-
)
|
|
226
|
+
await self._cleanup_existing_processes()
|
|
160
227
|
|
|
161
|
-
|
|
162
|
-
node_check = subprocess.run(
|
|
163
|
-
['node', '--version'],
|
|
164
|
-
capture_output=True,
|
|
165
|
-
text=True,
|
|
166
|
-
)
|
|
167
|
-
if node_check.returncode != 0:
|
|
168
|
-
raise RuntimeError(
|
|
169
|
-
"node is not installed or not in PATH. "
|
|
170
|
-
"Please install Node.js from https://nodejs.org/ "
|
|
171
|
-
"to use the hybrid browser toolkit."
|
|
172
|
-
)
|
|
228
|
+
npm_cmd, node_cmd = await check_and_install_dependencies(self.ts_dir)
|
|
173
229
|
|
|
174
|
-
|
|
175
|
-
node_modules_path = os.path.join(self.ts_dir, 'node_modules')
|
|
176
|
-
if not os.path.exists(node_modules_path):
|
|
177
|
-
logger.warning("Node modules not found. Running npm install...")
|
|
178
|
-
install_result = subprocess.run(
|
|
179
|
-
['npm', 'install'],
|
|
180
|
-
cwd=self.ts_dir,
|
|
181
|
-
capture_output=True,
|
|
182
|
-
text=True,
|
|
183
|
-
)
|
|
184
|
-
if install_result.returncode != 0:
|
|
185
|
-
logger.error(f"npm install failed: {install_result.stderr}")
|
|
186
|
-
raise RuntimeError(
|
|
187
|
-
f"Failed to install npm dependencies: {install_result.stderr}\n" # noqa:E501
|
|
188
|
-
f"Please run 'npm install' in {self.ts_dir} manually."
|
|
189
|
-
)
|
|
190
|
-
logger.info("npm dependencies installed successfully")
|
|
230
|
+
import platform
|
|
191
231
|
|
|
192
|
-
|
|
193
|
-
build_result = subprocess.run(
|
|
194
|
-
['npm', 'run', 'build'],
|
|
195
|
-
cwd=self.ts_dir,
|
|
196
|
-
capture_output=True,
|
|
197
|
-
text=True,
|
|
198
|
-
)
|
|
199
|
-
if build_result.returncode != 0:
|
|
200
|
-
logger.error(f"TypeScript build failed: {build_result.stderr}")
|
|
201
|
-
raise RuntimeError(
|
|
202
|
-
f"TypeScript build failed: {build_result.stderr}"
|
|
203
|
-
)
|
|
232
|
+
use_shell = platform.system() == 'Windows'
|
|
204
233
|
|
|
205
|
-
# Start the WebSocket server
|
|
206
234
|
self.process = subprocess.Popen(
|
|
207
|
-
[
|
|
235
|
+
[node_cmd, 'websocket-server.js'],
|
|
208
236
|
cwd=self.ts_dir,
|
|
209
237
|
stdout=subprocess.PIPE,
|
|
210
|
-
stderr=subprocess.
|
|
238
|
+
stderr=subprocess.STDOUT,
|
|
211
239
|
text=True,
|
|
240
|
+
encoding='utf-8',
|
|
241
|
+
bufsize=1,
|
|
242
|
+
shell=use_shell,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
self._server_ready_future = asyncio.get_running_loop().create_future()
|
|
246
|
+
|
|
247
|
+
self._log_reader_task = asyncio.create_task(
|
|
248
|
+
self._read_and_log_output()
|
|
212
249
|
)
|
|
213
250
|
|
|
214
|
-
|
|
251
|
+
if self.browser_log_to_file and self.ts_log_file_path:
|
|
252
|
+
logger.info(
|
|
253
|
+
f"TypeScript console logs will be written to: "
|
|
254
|
+
f"{self.ts_log_file_path}"
|
|
255
|
+
)
|
|
256
|
+
|
|
215
257
|
server_ready = False
|
|
216
|
-
timeout = 10
|
|
217
|
-
start_time = time.time()
|
|
258
|
+
timeout = 10
|
|
218
259
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
260
|
+
try:
|
|
261
|
+
await asyncio.wait_for(self._server_ready_future, timeout=timeout)
|
|
262
|
+
server_ready = True
|
|
263
|
+
except asyncio.TimeoutError:
|
|
264
|
+
server_ready = False
|
|
265
|
+
|
|
266
|
+
if not server_ready:
|
|
267
|
+
await _cleanup_process_and_tasks(
|
|
268
|
+
self.process,
|
|
269
|
+
self._log_reader_task,
|
|
270
|
+
getattr(self, 'ts_log_file', None),
|
|
271
|
+
)
|
|
272
|
+
self.ts_log_file = None
|
|
273
|
+
self.process = None
|
|
274
|
+
|
|
275
|
+
error_msg = _create_memory_aware_error(
|
|
276
|
+
"WebSocket server failed to start within timeout"
|
|
277
|
+
)
|
|
278
|
+
raise RuntimeError(error_msg)
|
|
226
279
|
|
|
280
|
+
max_retries = 3
|
|
281
|
+
retry_delays = [1, 2, 4]
|
|
282
|
+
|
|
283
|
+
for attempt in range(max_retries):
|
|
227
284
|
try:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
285
|
+
connect_timeout = 10.0 + (attempt * 5.0)
|
|
286
|
+
|
|
287
|
+
logger.info(
|
|
288
|
+
f"Attempting to connect to WebSocket server "
|
|
289
|
+
f"(attempt {attempt + 1}/{max_retries}, "
|
|
290
|
+
f"timeout: {connect_timeout}s)"
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
self.websocket = await asyncio.wait_for(
|
|
294
|
+
websockets.connect(
|
|
295
|
+
f"ws://localhost:{self.server_port}",
|
|
296
|
+
ping_interval=30,
|
|
297
|
+
ping_timeout=10,
|
|
298
|
+
max_size=50 * 1024 * 1024,
|
|
299
|
+
),
|
|
300
|
+
timeout=connect_timeout,
|
|
301
|
+
)
|
|
302
|
+
logger.info("Connected to WebSocket server")
|
|
303
|
+
break
|
|
304
|
+
|
|
305
|
+
except asyncio.TimeoutError:
|
|
306
|
+
if attempt < max_retries - 1:
|
|
307
|
+
delay = retry_delays[attempt]
|
|
308
|
+
logger.warning(
|
|
309
|
+
f"WebSocket handshake timeout "
|
|
310
|
+
f"(attempt {attempt + 1}/{max_retries}). "
|
|
311
|
+
f"Retrying in {delay} seconds..."
|
|
312
|
+
)
|
|
313
|
+
await asyncio.sleep(delay)
|
|
314
|
+
else:
|
|
315
|
+
raise RuntimeError(
|
|
316
|
+
f"Failed to connect to WebSocket server after "
|
|
317
|
+
f"{max_retries} attempts: Handshake timeout"
|
|
234
318
|
)
|
|
235
|
-
except (ValueError, IndexError):
|
|
236
|
-
continue
|
|
237
319
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
320
|
+
except Exception as e:
|
|
321
|
+
if attempt < max_retries - 1 and "timed out" in str(e).lower():
|
|
322
|
+
delay = retry_delays[attempt]
|
|
323
|
+
logger.warning(
|
|
324
|
+
f"WebSocket connection failed "
|
|
325
|
+
f"(attempt {attempt + 1}/{max_retries}): {e}. "
|
|
326
|
+
f"Retrying in {delay} seconds..."
|
|
327
|
+
)
|
|
328
|
+
await asyncio.sleep(delay)
|
|
329
|
+
else:
|
|
330
|
+
break
|
|
331
|
+
|
|
332
|
+
if not self.websocket:
|
|
333
|
+
await _cleanup_process_and_tasks(
|
|
334
|
+
self.process,
|
|
335
|
+
self._log_reader_task,
|
|
336
|
+
getattr(self, 'ts_log_file', None),
|
|
242
337
|
)
|
|
338
|
+
self.ts_log_file = None
|
|
339
|
+
self.process = None
|
|
243
340
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
self.websocket = await websockets.connect(
|
|
247
|
-
f"ws://localhost:{self.server_port}",
|
|
248
|
-
ping_interval=30,
|
|
249
|
-
ping_timeout=10,
|
|
250
|
-
max_size=50 * 1024 * 1024, # 50MB limit to match server
|
|
341
|
+
error_msg = _create_memory_aware_error(
|
|
342
|
+
"Failed to connect to WebSocket server after multiple attempts"
|
|
251
343
|
)
|
|
252
|
-
|
|
253
|
-
except Exception as e:
|
|
254
|
-
self.process.kill()
|
|
255
|
-
raise RuntimeError(
|
|
256
|
-
f"Failed to connect to WebSocket server: {e}"
|
|
257
|
-
) from e
|
|
344
|
+
raise RuntimeError(error_msg)
|
|
258
345
|
|
|
259
|
-
# Start the background receiver task
|
|
260
346
|
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
261
347
|
|
|
262
|
-
# Initialize the browser toolkit
|
|
263
348
|
await self._send_command('init', self.config)
|
|
264
349
|
|
|
350
|
+
if self.config.get('cdpUrl'):
|
|
351
|
+
self._browser_opened = True
|
|
352
|
+
|
|
265
353
|
async def stop(self):
|
|
266
354
|
"""Stop the WebSocket connection and server."""
|
|
267
|
-
# Cancel the receiver task
|
|
268
|
-
if self._receive_task and not self._receive_task.done():
|
|
269
|
-
self._receive_task.cancel()
|
|
270
|
-
try:
|
|
271
|
-
await self._receive_task
|
|
272
|
-
except asyncio.CancelledError:
|
|
273
|
-
pass
|
|
274
|
-
|
|
275
355
|
if self.websocket:
|
|
276
|
-
|
|
277
|
-
await
|
|
356
|
+
with contextlib.suppress(asyncio.TimeoutError, Exception):
|
|
357
|
+
await asyncio.wait_for(
|
|
358
|
+
self._send_command('shutdown', {}),
|
|
359
|
+
timeout=2.0,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
with contextlib.suppress(Exception):
|
|
278
363
|
await self.websocket.close()
|
|
364
|
+
self.websocket = None
|
|
365
|
+
|
|
366
|
+
self._browser_opened = False
|
|
367
|
+
|
|
368
|
+
# Gracefully stop the Node process before cancelling the log reader
|
|
369
|
+
if self.process:
|
|
370
|
+
try:
|
|
371
|
+
# give the process a short grace period to exit after shutdown
|
|
372
|
+
self.process.wait(timeout=2)
|
|
373
|
+
except subprocess.TimeoutExpired:
|
|
374
|
+
try:
|
|
375
|
+
self.process.terminate()
|
|
376
|
+
self.process.wait(timeout=3)
|
|
377
|
+
except subprocess.TimeoutExpired:
|
|
378
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
379
|
+
self.process.kill()
|
|
380
|
+
self.process.wait()
|
|
381
|
+
except Exception as e:
|
|
382
|
+
logger.warning(f"Error terminating process: {e}")
|
|
279
383
|
except Exception as e:
|
|
280
|
-
logger.warning(f"Error
|
|
281
|
-
|
|
282
|
-
|
|
384
|
+
logger.warning(f"Error waiting for process: {e}")
|
|
385
|
+
|
|
386
|
+
# Now cancel background tasks (reader won't block on readline)
|
|
387
|
+
tasks_to_cancel = [
|
|
388
|
+
('_receive_task', self._receive_task),
|
|
389
|
+
('_log_reader_task', self._log_reader_task),
|
|
390
|
+
]
|
|
391
|
+
for _, task in tasks_to_cancel:
|
|
392
|
+
if task and not task.done():
|
|
393
|
+
task.cancel()
|
|
394
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
395
|
+
await task
|
|
396
|
+
|
|
397
|
+
# Close TS log file if open
|
|
398
|
+
if getattr(self, 'ts_log_file', None):
|
|
399
|
+
with contextlib.suppress(Exception):
|
|
400
|
+
self.ts_log_file.close()
|
|
401
|
+
self.ts_log_file = None
|
|
402
|
+
|
|
403
|
+
# Ensure process handle cleared
|
|
404
|
+
self.process = None
|
|
405
|
+
|
|
406
|
+
async def disconnect_only(self):
|
|
407
|
+
"""Disconnect WebSocket and stop server without closing the browser.
|
|
408
|
+
|
|
409
|
+
This is useful for CDP mode where the browser should remain open.
|
|
410
|
+
"""
|
|
411
|
+
if self.websocket:
|
|
412
|
+
with contextlib.suppress(Exception):
|
|
413
|
+
await self.websocket.close()
|
|
414
|
+
self.websocket = None
|
|
415
|
+
|
|
416
|
+
self._browser_opened = False
|
|
283
417
|
|
|
418
|
+
# Stop the Node process
|
|
284
419
|
if self.process:
|
|
285
420
|
try:
|
|
421
|
+
# Send SIGTERM to gracefully shutdown
|
|
286
422
|
self.process.terminate()
|
|
287
|
-
self.process.wait(timeout=
|
|
423
|
+
self.process.wait(timeout=3)
|
|
288
424
|
except subprocess.TimeoutExpired:
|
|
289
|
-
|
|
290
|
-
|
|
425
|
+
# Force kill if needed
|
|
426
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
427
|
+
self.process.kill()
|
|
428
|
+
self.process.wait()
|
|
291
429
|
except Exception as e:
|
|
292
430
|
logger.warning(f"Error terminating process: {e}")
|
|
293
|
-
|
|
294
|
-
|
|
431
|
+
|
|
432
|
+
# Cancel background tasks
|
|
433
|
+
tasks_to_cancel = [
|
|
434
|
+
('_receive_task', self._receive_task),
|
|
435
|
+
('_log_reader_task', self._log_reader_task),
|
|
436
|
+
]
|
|
437
|
+
for _, task in tasks_to_cancel:
|
|
438
|
+
if task and not task.done():
|
|
439
|
+
task.cancel()
|
|
440
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
441
|
+
await task
|
|
442
|
+
|
|
443
|
+
# Close TS log file if open
|
|
444
|
+
if getattr(self, 'ts_log_file', None):
|
|
445
|
+
with contextlib.suppress(Exception):
|
|
446
|
+
self.ts_log_file.close()
|
|
447
|
+
self.ts_log_file = None
|
|
448
|
+
|
|
449
|
+
# Ensure process handle cleared
|
|
450
|
+
self.process = None
|
|
451
|
+
|
|
452
|
+
logger.info("WebSocket disconnected without closing browser")
|
|
295
453
|
|
|
296
454
|
async def _log_action(
|
|
297
455
|
self,
|
|
@@ -366,7 +524,16 @@ class WebSocketBrowserWrapper:
|
|
|
366
524
|
except asyncio.CancelledError:
|
|
367
525
|
break
|
|
368
526
|
except Exception as e:
|
|
369
|
-
|
|
527
|
+
# Check if it's a normal WebSocket close
|
|
528
|
+
if isinstance(e, websockets.exceptions.ConnectionClosed):
|
|
529
|
+
if e.code == 1000: # Normal closure
|
|
530
|
+
logger.debug(f"WebSocket closed normally: {e}")
|
|
531
|
+
else:
|
|
532
|
+
logger.warning(
|
|
533
|
+
f"WebSocket closed with code {e.code}: {e}"
|
|
534
|
+
)
|
|
535
|
+
else:
|
|
536
|
+
logger.error(f"Error in receive loop: {e}")
|
|
370
537
|
# Notify all pending futures of the error
|
|
371
538
|
for future in self._pending_responses.values():
|
|
372
539
|
if not future.done():
|
|
@@ -379,16 +546,20 @@ class WebSocketBrowserWrapper:
|
|
|
379
546
|
async def _ensure_connection(self) -> None:
|
|
380
547
|
"""Ensure WebSocket connection is alive."""
|
|
381
548
|
if not self.websocket:
|
|
382
|
-
|
|
549
|
+
error_msg = _create_memory_aware_error("WebSocket not connected")
|
|
550
|
+
raise RuntimeError(error_msg)
|
|
383
551
|
|
|
384
552
|
# Check if connection is still alive
|
|
385
553
|
try:
|
|
386
|
-
# Send a ping
|
|
387
|
-
await self.websocket.ping()
|
|
554
|
+
# Send a ping and wait for the corresponding pong (bounded wait)
|
|
555
|
+
pong_waiter = await self.websocket.ping()
|
|
556
|
+
await asyncio.wait_for(pong_waiter, timeout=5.0)
|
|
388
557
|
except Exception as e:
|
|
389
558
|
logger.warning(f"WebSocket ping failed: {e}")
|
|
390
559
|
self.websocket = None
|
|
391
|
-
|
|
560
|
+
|
|
561
|
+
error_msg = _create_memory_aware_error("WebSocket connection lost")
|
|
562
|
+
raise RuntimeError(error_msg)
|
|
392
563
|
|
|
393
564
|
async def _send_command(
|
|
394
565
|
self, command: str, params: Dict[str, Any]
|
|
@@ -403,7 +574,8 @@ class WebSocketBrowserWrapper:
|
|
|
403
574
|
message = {'id': message_id, 'command': command, 'params': params}
|
|
404
575
|
|
|
405
576
|
# Create a future for this message
|
|
406
|
-
|
|
577
|
+
loop = asyncio.get_running_loop()
|
|
578
|
+
future: asyncio.Future[Dict[str, Any]] = loop.create_future()
|
|
407
579
|
self._pending_responses[message_id] = future
|
|
408
580
|
|
|
409
581
|
try:
|
|
@@ -427,6 +599,16 @@ class WebSocketBrowserWrapper:
|
|
|
427
599
|
except asyncio.TimeoutError:
|
|
428
600
|
# Remove from pending if timeout
|
|
429
601
|
self._pending_responses.pop(message_id, None)
|
|
602
|
+
# Special handling for shutdown command
|
|
603
|
+
if command == 'shutdown':
|
|
604
|
+
logger.debug(
|
|
605
|
+
"Shutdown command timeout is expected - "
|
|
606
|
+
"server may have closed before responding"
|
|
607
|
+
)
|
|
608
|
+
# Return a success response for shutdown
|
|
609
|
+
return {
|
|
610
|
+
'message': 'Browser shutdown (no response received)'
|
|
611
|
+
}
|
|
430
612
|
raise RuntimeError(
|
|
431
613
|
f"Timeout waiting for response to command: {command}"
|
|
432
614
|
)
|
|
@@ -440,6 +622,12 @@ class WebSocketBrowserWrapper:
|
|
|
440
622
|
"close frame" in str(e)
|
|
441
623
|
or "connection closed" in str(e).lower()
|
|
442
624
|
):
|
|
625
|
+
# Special handling for shutdown command
|
|
626
|
+
if command == 'shutdown':
|
|
627
|
+
logger.debug(
|
|
628
|
+
f"Connection closed during shutdown (expected): {e}"
|
|
629
|
+
)
|
|
630
|
+
return {'message': 'Browser shutdown (connection closed)'}
|
|
443
631
|
logger.error(f"WebSocket connection closed unexpectedly: {e}")
|
|
444
632
|
# Mark connection as closed
|
|
445
633
|
self.websocket = None
|
|
@@ -460,17 +648,31 @@ class WebSocketBrowserWrapper:
|
|
|
460
648
|
response = await self._send_command(
|
|
461
649
|
'open_browser', {'startUrl': start_url}
|
|
462
650
|
)
|
|
651
|
+
self._browser_opened = True
|
|
463
652
|
return response
|
|
464
653
|
|
|
465
654
|
@action_logger
|
|
466
655
|
async def close_browser(self) -> str:
|
|
467
656
|
"""Close browser."""
|
|
468
657
|
response = await self._send_command('close_browser', {})
|
|
658
|
+
self._browser_opened = False
|
|
469
659
|
return response['message']
|
|
470
660
|
|
|
471
661
|
@action_logger
|
|
472
662
|
async def visit_page(self, url: str) -> Dict[str, Any]:
|
|
473
|
-
"""Visit a page.
|
|
663
|
+
"""Visit a page.
|
|
664
|
+
|
|
665
|
+
In non-CDP mode, automatically opens browser if not already open.
|
|
666
|
+
"""
|
|
667
|
+
if not self._browser_opened:
|
|
668
|
+
is_cdp_mode = bool(self.config.get('cdpUrl'))
|
|
669
|
+
|
|
670
|
+
if not is_cdp_mode:
|
|
671
|
+
logger.info(
|
|
672
|
+
"Browser not open, automatically opening browser..."
|
|
673
|
+
)
|
|
674
|
+
await self.open_browser()
|
|
675
|
+
|
|
474
676
|
response = await self._send_command('visit_page', {'url': url})
|
|
475
677
|
return response
|
|
476
678
|
|
|
@@ -565,6 +767,8 @@ class WebSocketBrowserWrapper:
|
|
|
565
767
|
async def type(self, ref: str, text: str) -> Dict[str, Any]:
|
|
566
768
|
"""Type text into an element."""
|
|
567
769
|
response = await self._send_command('type', {'ref': ref, 'text': text})
|
|
770
|
+
# Log the response for debugging
|
|
771
|
+
logger.debug(f"Type response for ref {ref}: {response}")
|
|
568
772
|
return response
|
|
569
773
|
|
|
570
774
|
@action_logger
|
|
@@ -681,3 +885,73 @@ class WebSocketBrowserWrapper:
|
|
|
681
885
|
'wait_user', {'timeout': timeout_sec}
|
|
682
886
|
)
|
|
683
887
|
return response
|
|
888
|
+
|
|
889
|
+
async def _read_and_log_output(self):
|
|
890
|
+
"""Read stdout from Node.js process & handle SERVER_READY + logging."""
|
|
891
|
+
if not self.process:
|
|
892
|
+
return
|
|
893
|
+
|
|
894
|
+
try:
|
|
895
|
+
with contextlib.ExitStack() as stack:
|
|
896
|
+
if self.ts_log_file_path:
|
|
897
|
+
self.ts_log_file = stack.enter_context(
|
|
898
|
+
open(self.ts_log_file_path, 'w', encoding='utf-8')
|
|
899
|
+
)
|
|
900
|
+
self.ts_log_file.write(
|
|
901
|
+
f"TypeScript Console Log - Started at "
|
|
902
|
+
f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
903
|
+
)
|
|
904
|
+
self.ts_log_file.write("=" * 80 + "\n")
|
|
905
|
+
self.ts_log_file.flush()
|
|
906
|
+
|
|
907
|
+
while self.process and self.process.poll() is None:
|
|
908
|
+
try:
|
|
909
|
+
line = (
|
|
910
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
911
|
+
None, self.process.stdout.readline
|
|
912
|
+
)
|
|
913
|
+
)
|
|
914
|
+
if not line: # EOF
|
|
915
|
+
break
|
|
916
|
+
|
|
917
|
+
# Check for SERVER_READY message
|
|
918
|
+
if line.startswith('SERVER_READY:'):
|
|
919
|
+
try:
|
|
920
|
+
self.server_port = int(
|
|
921
|
+
line.split(':', 1)[1].strip()
|
|
922
|
+
)
|
|
923
|
+
logger.info(
|
|
924
|
+
f"WebSocket server ready on port "
|
|
925
|
+
f"{self.server_port}"
|
|
926
|
+
)
|
|
927
|
+
if (
|
|
928
|
+
self._server_ready_future
|
|
929
|
+
and not self._server_ready_future.done()
|
|
930
|
+
):
|
|
931
|
+
self._server_ready_future.set_result(True)
|
|
932
|
+
except (ValueError, IndexError) as e:
|
|
933
|
+
logger.error(
|
|
934
|
+
f"Failed to parse SERVER_READY: {e}"
|
|
935
|
+
)
|
|
936
|
+
|
|
937
|
+
# Write all output to log file
|
|
938
|
+
if self.ts_log_file:
|
|
939
|
+
timestamp = time.strftime('%H:%M:%S')
|
|
940
|
+
self.ts_log_file.write(f"[{timestamp}] {line}")
|
|
941
|
+
self.ts_log_file.flush()
|
|
942
|
+
|
|
943
|
+
except Exception as e:
|
|
944
|
+
logger.warning(f"Error reading stdout: {e}")
|
|
945
|
+
break
|
|
946
|
+
|
|
947
|
+
# Footer if we had a file
|
|
948
|
+
if self.ts_log_file:
|
|
949
|
+
self.ts_log_file.write("\n" + "=" * 80 + "\n")
|
|
950
|
+
self.ts_log_file.write(
|
|
951
|
+
f"TypeScript Console Log - Ended at "
|
|
952
|
+
f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
953
|
+
)
|
|
954
|
+
# ExitStack closes file; clear handle
|
|
955
|
+
self.ts_log_file = None
|
|
956
|
+
except Exception as e:
|
|
957
|
+
logger.warning(f"Error in _read_and_log_output: {e}")
|