camel-ai 0.2.75a6__py3-none-any.whl → 0.2.76a1__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 +159 -38
- camel/configs/__init__.py +3 -0
- camel/configs/amd_config.py +70 -0
- camel/interpreters/__init__.py +2 -0
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/memories/__init__.py +2 -1
- camel/memories/agent_memories.py +3 -1
- camel/memories/blocks/chat_history_block.py +17 -2
- camel/models/__init__.py +2 -0
- camel/models/amd_model.py +101 -0
- camel/models/model_factory.py +2 -0
- camel/models/openai_model.py +0 -6
- camel/runtimes/daytona_runtime.py +11 -12
- camel/societies/workforce/single_agent_worker.py +44 -38
- camel/storages/object_storages/google_cloud.py +1 -1
- camel/toolkits/__init__.py +14 -5
- camel/toolkits/aci_toolkit.py +45 -0
- camel/toolkits/code_execution.py +28 -1
- camel/toolkits/context_summarizer_toolkit.py +683 -0
- camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
- camel/toolkits/function_tool.py +6 -1
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +19 -2
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
- 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 +401 -80
- 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 +39 -14
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/note_taking_toolkit.py +18 -8
- camel/toolkits/terminal_toolkit.py +12 -2
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +16 -10
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/types/enums.py +11 -0
- camel/utils/commons.py +2 -0
- camel/utils/context_utils.py +395 -0
- camel/utils/mcp.py +136 -2
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.dist-info}/METADATA +6 -3
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.dist-info}/RECORD +52 -41
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.dist-info}/WHEEL +0 -0
- {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.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
|
|
@@ -44,23 +45,19 @@ def action_logger(func):
|
|
|
44
45
|
action_name = func.__name__
|
|
45
46
|
start_time = time.time()
|
|
46
47
|
|
|
47
|
-
# Log inputs (skip self)
|
|
48
48
|
inputs = {
|
|
49
49
|
"args": args,
|
|
50
50
|
"kwargs": kwargs,
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
try:
|
|
54
|
-
# Execute the original function
|
|
55
54
|
result = await func(self, *args, **kwargs)
|
|
56
55
|
execution_time = time.time() - start_time
|
|
57
56
|
|
|
58
|
-
# Extract page load time if available
|
|
59
57
|
page_load_time = None
|
|
60
58
|
if isinstance(result, dict) and 'page_load_time_ms' in result:
|
|
61
59
|
page_load_time = result['page_load_time_ms'] / 1000.0
|
|
62
60
|
|
|
63
|
-
# Log success
|
|
64
61
|
await self._log_action(
|
|
65
62
|
action_name=action_name,
|
|
66
63
|
inputs=inputs,
|
|
@@ -75,7 +72,6 @@ def action_logger(func):
|
|
|
75
72
|
execution_time = time.time() - start_time
|
|
76
73
|
error_msg = f"{type(e).__name__}: {e!s}"
|
|
77
74
|
|
|
78
|
-
# Log error
|
|
79
75
|
await self._log_action(
|
|
80
76
|
action_name=action_name,
|
|
81
77
|
inputs=inputs,
|
|
@@ -110,29 +106,34 @@ class WebSocketBrowserWrapper:
|
|
|
110
106
|
self.process: Optional[subprocess.Popen] = None
|
|
111
107
|
self.websocket = None
|
|
112
108
|
self.server_port = None
|
|
113
|
-
self._send_lock = asyncio.Lock()
|
|
114
|
-
self._receive_task = None
|
|
115
|
-
self._pending_responses: Dict[
|
|
116
|
-
|
|
117
|
-
] = {} # Message ID -> Future
|
|
109
|
+
self._send_lock = asyncio.Lock()
|
|
110
|
+
self._receive_task = None
|
|
111
|
+
self._pending_responses: Dict[str, asyncio.Future[Dict[str, Any]]] = {}
|
|
112
|
+
self._server_ready_future = None
|
|
118
113
|
|
|
119
|
-
# Logging configuration
|
|
120
114
|
self.browser_log_to_file = (config or {}).get(
|
|
121
115
|
'browser_log_to_file', False
|
|
122
116
|
)
|
|
117
|
+
self.log_dir = (config or {}).get('log_dir', 'browser_log')
|
|
123
118
|
self.session_id = (config or {}).get('session_id', 'default')
|
|
124
119
|
self.log_file_path: Optional[str] = None
|
|
125
120
|
self.log_buffer: List[Dict[str, Any]] = []
|
|
121
|
+
self.ts_log_file_path: Optional[str] = None
|
|
122
|
+
self.ts_log_file = None
|
|
123
|
+
self._log_reader_task = None
|
|
126
124
|
|
|
127
|
-
# Set up log file if needed
|
|
128
125
|
if self.browser_log_to_file:
|
|
129
|
-
log_dir = "browser_log"
|
|
126
|
+
log_dir = self.log_dir if self.log_dir else "browser_log"
|
|
130
127
|
os.makedirs(log_dir, exist_ok=True)
|
|
131
128
|
timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
|
132
129
|
self.log_file_path = os.path.join(
|
|
133
130
|
log_dir,
|
|
134
131
|
f"hybrid_browser_toolkit_ws_{timestamp}_{self.session_id}.log",
|
|
135
132
|
)
|
|
133
|
+
self.ts_log_file_path = os.path.join(
|
|
134
|
+
log_dir,
|
|
135
|
+
f"typescript_console_{timestamp}_{self.session_id}.log",
|
|
136
|
+
)
|
|
136
137
|
|
|
137
138
|
async def __aenter__(self):
|
|
138
139
|
"""Async context manager entry."""
|
|
@@ -143,9 +144,53 @@ class WebSocketBrowserWrapper:
|
|
|
143
144
|
"""Async context manager exit."""
|
|
144
145
|
await self.stop()
|
|
145
146
|
|
|
147
|
+
async def _cleanup_existing_processes(self):
|
|
148
|
+
"""Clean up any existing Node.js WebSocket server processes."""
|
|
149
|
+
import psutil
|
|
150
|
+
|
|
151
|
+
cleaned_count = 0
|
|
152
|
+
for proc in psutil.process_iter(['pid', 'name', 'cmdline']):
|
|
153
|
+
try:
|
|
154
|
+
if (
|
|
155
|
+
proc.info['name']
|
|
156
|
+
and 'node' in proc.info['name'].lower()
|
|
157
|
+
and proc.info['cmdline']
|
|
158
|
+
and any(
|
|
159
|
+
'websocket-server.js' in arg
|
|
160
|
+
for arg in proc.info['cmdline']
|
|
161
|
+
)
|
|
162
|
+
):
|
|
163
|
+
if any(self.ts_dir in arg for arg in proc.info['cmdline']):
|
|
164
|
+
logger.warning(
|
|
165
|
+
f"Found existing WebSocket server process "
|
|
166
|
+
f"(PID: {proc.info['pid']}). "
|
|
167
|
+
f"Terminating it to prevent conflicts."
|
|
168
|
+
)
|
|
169
|
+
proc.terminate()
|
|
170
|
+
try:
|
|
171
|
+
proc.wait(timeout=3)
|
|
172
|
+
except psutil.TimeoutExpired:
|
|
173
|
+
proc.kill()
|
|
174
|
+
cleaned_count += 1
|
|
175
|
+
except (
|
|
176
|
+
psutil.NoSuchProcess,
|
|
177
|
+
psutil.AccessDenied,
|
|
178
|
+
psutil.ZombieProcess,
|
|
179
|
+
):
|
|
180
|
+
pass
|
|
181
|
+
|
|
182
|
+
if cleaned_count > 0:
|
|
183
|
+
logger.warning(
|
|
184
|
+
f"Cleaned up {cleaned_count} existing WebSocket server "
|
|
185
|
+
f"process(es). This may have been caused by improper "
|
|
186
|
+
f"shutdown in previous sessions."
|
|
187
|
+
)
|
|
188
|
+
await asyncio.sleep(0.5)
|
|
189
|
+
|
|
146
190
|
async def start(self):
|
|
147
191
|
"""Start the WebSocket server and connect to it."""
|
|
148
|
-
|
|
192
|
+
await self._cleanup_existing_processes()
|
|
193
|
+
|
|
149
194
|
npm_check = subprocess.run(
|
|
150
195
|
['npm', '--version'],
|
|
151
196
|
capture_output=True,
|
|
@@ -158,7 +203,6 @@ class WebSocketBrowserWrapper:
|
|
|
158
203
|
"to use the hybrid browser toolkit."
|
|
159
204
|
)
|
|
160
205
|
|
|
161
|
-
# Check if node is installed
|
|
162
206
|
node_check = subprocess.run(
|
|
163
207
|
['node', '--version'],
|
|
164
208
|
capture_output=True,
|
|
@@ -171,7 +215,6 @@ class WebSocketBrowserWrapper:
|
|
|
171
215
|
"to use the hybrid browser toolkit."
|
|
172
216
|
)
|
|
173
217
|
|
|
174
|
-
# Check if node_modules exists (dependencies installed)
|
|
175
218
|
node_modules_path = os.path.join(self.ts_dir, 'node_modules')
|
|
176
219
|
if not os.path.exists(node_modules_path):
|
|
177
220
|
logger.warning("Node modules not found. Running npm install...")
|
|
@@ -189,7 +232,6 @@ class WebSocketBrowserWrapper:
|
|
|
189
232
|
)
|
|
190
233
|
logger.info("npm dependencies installed successfully")
|
|
191
234
|
|
|
192
|
-
# Ensure the TypeScript code is built
|
|
193
235
|
build_result = subprocess.run(
|
|
194
236
|
['npm', 'run', 'build'],
|
|
195
237
|
cwd=self.ts_dir,
|
|
@@ -202,96 +244,251 @@ class WebSocketBrowserWrapper:
|
|
|
202
244
|
f"TypeScript build failed: {build_result.stderr}"
|
|
203
245
|
)
|
|
204
246
|
|
|
205
|
-
# Start the WebSocket server
|
|
206
247
|
self.process = subprocess.Popen(
|
|
207
248
|
['node', 'websocket-server.js'],
|
|
208
249
|
cwd=self.ts_dir,
|
|
209
250
|
stdout=subprocess.PIPE,
|
|
210
|
-
stderr=subprocess.
|
|
251
|
+
stderr=subprocess.STDOUT,
|
|
211
252
|
text=True,
|
|
253
|
+
bufsize=1,
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
self._server_ready_future = asyncio.get_running_loop().create_future()
|
|
257
|
+
|
|
258
|
+
self._log_reader_task = asyncio.create_task(
|
|
259
|
+
self._read_and_log_output()
|
|
212
260
|
)
|
|
213
261
|
|
|
214
|
-
|
|
262
|
+
if self.browser_log_to_file and self.ts_log_file_path:
|
|
263
|
+
logger.info(
|
|
264
|
+
f"TypeScript console logs will be written to: "
|
|
265
|
+
f"{self.ts_log_file_path}"
|
|
266
|
+
)
|
|
267
|
+
|
|
215
268
|
server_ready = False
|
|
216
|
-
timeout = 10
|
|
217
|
-
start_time = time.time()
|
|
269
|
+
timeout = 10
|
|
218
270
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
271
|
+
try:
|
|
272
|
+
await asyncio.wait_for(self._server_ready_future, timeout=timeout)
|
|
273
|
+
server_ready = True
|
|
274
|
+
except asyncio.TimeoutError:
|
|
275
|
+
server_ready = False
|
|
276
|
+
|
|
277
|
+
if not server_ready:
|
|
278
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
279
|
+
self.process.kill()
|
|
280
|
+
with contextlib.suppress(Exception):
|
|
281
|
+
self.process.wait(timeout=2)
|
|
282
|
+
if self._log_reader_task and not self._log_reader_task.done():
|
|
283
|
+
self._log_reader_task.cancel()
|
|
284
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
285
|
+
await self._log_reader_task
|
|
286
|
+
if getattr(self, 'ts_log_file', None):
|
|
287
|
+
with contextlib.suppress(Exception):
|
|
288
|
+
self.ts_log_file.close()
|
|
289
|
+
self.ts_log_file = None
|
|
290
|
+
self.process = None
|
|
291
|
+
|
|
292
|
+
error_msg = "WebSocket server failed to start within timeout"
|
|
293
|
+
import psutil
|
|
294
|
+
|
|
295
|
+
mem = psutil.virtual_memory()
|
|
296
|
+
if mem.available < 1024**3:
|
|
297
|
+
error_msg = (
|
|
298
|
+
f"WebSocket server failed to start"
|
|
299
|
+
f"(likely due to insufficient memory). "
|
|
300
|
+
f"Available memory: {mem.available / 1024**3:.2f}GB "
|
|
301
|
+
f"({mem.percent}% used)"
|
|
225
302
|
)
|
|
226
303
|
|
|
304
|
+
raise RuntimeError(error_msg)
|
|
305
|
+
|
|
306
|
+
max_retries = 3
|
|
307
|
+
retry_delays = [1, 2, 4]
|
|
308
|
+
|
|
309
|
+
for attempt in range(max_retries):
|
|
227
310
|
try:
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
311
|
+
connect_timeout = 10.0 + (attempt * 5.0)
|
|
312
|
+
|
|
313
|
+
logger.info(
|
|
314
|
+
f"Attempting to connect to WebSocket server "
|
|
315
|
+
f"(attempt {attempt + 1}/{max_retries}, "
|
|
316
|
+
f"timeout: {connect_timeout}s)"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
self.websocket = await asyncio.wait_for(
|
|
320
|
+
websockets.connect(
|
|
321
|
+
f"ws://localhost:{self.server_port}",
|
|
322
|
+
ping_interval=30,
|
|
323
|
+
ping_timeout=10,
|
|
324
|
+
max_size=50 * 1024 * 1024,
|
|
325
|
+
),
|
|
326
|
+
timeout=connect_timeout,
|
|
327
|
+
)
|
|
328
|
+
logger.info("Connected to WebSocket server")
|
|
329
|
+
break
|
|
330
|
+
|
|
331
|
+
except asyncio.TimeoutError:
|
|
332
|
+
if attempt < max_retries - 1:
|
|
333
|
+
delay = retry_delays[attempt]
|
|
334
|
+
logger.warning(
|
|
335
|
+
f"WebSocket handshake timeout "
|
|
336
|
+
f"(attempt {attempt + 1}/{max_retries}). "
|
|
337
|
+
f"Retrying in {delay} seconds..."
|
|
338
|
+
)
|
|
339
|
+
await asyncio.sleep(delay)
|
|
340
|
+
else:
|
|
341
|
+
raise RuntimeError(
|
|
342
|
+
f"Failed to connect to WebSocket server after "
|
|
343
|
+
f"{max_retries} attempts: Handshake timeout"
|
|
234
344
|
)
|
|
235
|
-
except (ValueError, IndexError):
|
|
236
|
-
continue
|
|
237
345
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
346
|
+
except Exception as e:
|
|
347
|
+
if attempt < max_retries - 1 and "timed out" in str(e).lower():
|
|
348
|
+
delay = retry_delays[attempt]
|
|
349
|
+
logger.warning(
|
|
350
|
+
f"WebSocket connection failed "
|
|
351
|
+
f"(attempt {attempt + 1}/{max_retries}): {e}. "
|
|
352
|
+
f"Retrying in {delay} seconds..."
|
|
353
|
+
)
|
|
354
|
+
await asyncio.sleep(delay)
|
|
355
|
+
else:
|
|
356
|
+
break
|
|
243
357
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
358
|
+
if not self.websocket:
|
|
359
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
360
|
+
self.process.kill()
|
|
361
|
+
with contextlib.suppress(Exception):
|
|
362
|
+
self.process.wait(timeout=2)
|
|
363
|
+
if self._log_reader_task and not self._log_reader_task.done():
|
|
364
|
+
self._log_reader_task.cancel()
|
|
365
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
366
|
+
await self._log_reader_task
|
|
367
|
+
if getattr(self, 'ts_log_file', None):
|
|
368
|
+
with contextlib.suppress(Exception):
|
|
369
|
+
self.ts_log_file.close()
|
|
370
|
+
self.ts_log_file = None
|
|
371
|
+
self.process = None
|
|
372
|
+
|
|
373
|
+
import psutil
|
|
374
|
+
|
|
375
|
+
mem = psutil.virtual_memory()
|
|
376
|
+
|
|
377
|
+
error_msg = (
|
|
378
|
+
"Failed to connect to WebSocket server after multiple attempts"
|
|
251
379
|
)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
380
|
+
if mem.available < 1024**3:
|
|
381
|
+
error_msg = (
|
|
382
|
+
f"Failed to connect to WebSocket server "
|
|
383
|
+
f"(likely due to insufficient memory). "
|
|
384
|
+
f"Available memory: {mem.available / 1024**3:.2f}GB "
|
|
385
|
+
f"({mem.percent}% used)"
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
raise RuntimeError(error_msg)
|
|
258
389
|
|
|
259
|
-
# Start the background receiver task
|
|
260
390
|
self._receive_task = asyncio.create_task(self._receive_loop())
|
|
261
391
|
|
|
262
|
-
# Initialize the browser toolkit
|
|
263
392
|
await self._send_command('init', self.config)
|
|
264
393
|
|
|
265
394
|
async def stop(self):
|
|
266
395
|
"""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
396
|
if self.websocket:
|
|
276
|
-
|
|
277
|
-
await
|
|
397
|
+
with contextlib.suppress(asyncio.TimeoutError, Exception):
|
|
398
|
+
await asyncio.wait_for(
|
|
399
|
+
self._send_command('shutdown', {}),
|
|
400
|
+
timeout=2.0,
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
# Close websocket connection
|
|
404
|
+
with contextlib.suppress(Exception):
|
|
278
405
|
await self.websocket.close()
|
|
406
|
+
self.websocket = None
|
|
407
|
+
|
|
408
|
+
# Gracefully stop the Node process before cancelling the log reader
|
|
409
|
+
if self.process:
|
|
410
|
+
try:
|
|
411
|
+
# give the process a short grace period to exit after shutdown
|
|
412
|
+
self.process.wait(timeout=2)
|
|
413
|
+
except subprocess.TimeoutExpired:
|
|
414
|
+
try:
|
|
415
|
+
self.process.terminate()
|
|
416
|
+
self.process.wait(timeout=3)
|
|
417
|
+
except subprocess.TimeoutExpired:
|
|
418
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
419
|
+
self.process.kill()
|
|
420
|
+
self.process.wait()
|
|
421
|
+
except Exception as e:
|
|
422
|
+
logger.warning(f"Error terminating process: {e}")
|
|
279
423
|
except Exception as e:
|
|
280
|
-
logger.warning(f"Error
|
|
281
|
-
|
|
282
|
-
|
|
424
|
+
logger.warning(f"Error waiting for process: {e}")
|
|
425
|
+
|
|
426
|
+
# Now cancel background tasks (reader won't block on readline)
|
|
427
|
+
tasks_to_cancel = [
|
|
428
|
+
('_receive_task', self._receive_task),
|
|
429
|
+
('_log_reader_task', self._log_reader_task),
|
|
430
|
+
]
|
|
431
|
+
for _, task in tasks_to_cancel:
|
|
432
|
+
if task and not task.done():
|
|
433
|
+
task.cancel()
|
|
434
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
435
|
+
await task
|
|
436
|
+
|
|
437
|
+
# Close TS log file if open
|
|
438
|
+
if getattr(self, 'ts_log_file', None):
|
|
439
|
+
with contextlib.suppress(Exception):
|
|
440
|
+
self.ts_log_file.close()
|
|
441
|
+
self.ts_log_file = None
|
|
442
|
+
|
|
443
|
+
# Ensure process handle cleared
|
|
444
|
+
self.process = None
|
|
445
|
+
|
|
446
|
+
async def disconnect_only(self):
|
|
447
|
+
"""Disconnect WebSocket and stop server without closing the browser.
|
|
448
|
+
|
|
449
|
+
This is useful for CDP mode where the browser should remain open.
|
|
450
|
+
"""
|
|
451
|
+
# Close websocket connection
|
|
452
|
+
if self.websocket:
|
|
453
|
+
with contextlib.suppress(Exception):
|
|
454
|
+
await self.websocket.close()
|
|
455
|
+
self.websocket = None
|
|
283
456
|
|
|
457
|
+
# Stop the Node process
|
|
284
458
|
if self.process:
|
|
285
459
|
try:
|
|
460
|
+
# Send SIGTERM to gracefully shutdown
|
|
286
461
|
self.process.terminate()
|
|
287
|
-
self.process.wait(timeout=
|
|
462
|
+
self.process.wait(timeout=3)
|
|
288
463
|
except subprocess.TimeoutExpired:
|
|
289
|
-
|
|
290
|
-
|
|
464
|
+
# Force kill if needed
|
|
465
|
+
with contextlib.suppress(ProcessLookupError, Exception):
|
|
466
|
+
self.process.kill()
|
|
467
|
+
self.process.wait()
|
|
291
468
|
except Exception as e:
|
|
292
469
|
logger.warning(f"Error terminating process: {e}")
|
|
293
|
-
|
|
294
|
-
|
|
470
|
+
|
|
471
|
+
# Cancel background tasks
|
|
472
|
+
tasks_to_cancel = [
|
|
473
|
+
('_receive_task', self._receive_task),
|
|
474
|
+
('_log_reader_task', self._log_reader_task),
|
|
475
|
+
]
|
|
476
|
+
for _, task in tasks_to_cancel:
|
|
477
|
+
if task and not task.done():
|
|
478
|
+
task.cancel()
|
|
479
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
480
|
+
await task
|
|
481
|
+
|
|
482
|
+
# Close TS log file if open
|
|
483
|
+
if getattr(self, 'ts_log_file', None):
|
|
484
|
+
with contextlib.suppress(Exception):
|
|
485
|
+
self.ts_log_file.close()
|
|
486
|
+
self.ts_log_file = None
|
|
487
|
+
|
|
488
|
+
# Ensure process handle cleared
|
|
489
|
+
self.process = None
|
|
490
|
+
|
|
491
|
+
logger.info("WebSocket disconnected without closing browser")
|
|
295
492
|
|
|
296
493
|
async def _log_action(
|
|
297
494
|
self,
|
|
@@ -366,7 +563,16 @@ class WebSocketBrowserWrapper:
|
|
|
366
563
|
except asyncio.CancelledError:
|
|
367
564
|
break
|
|
368
565
|
except Exception as e:
|
|
369
|
-
|
|
566
|
+
# Check if it's a normal WebSocket close
|
|
567
|
+
if isinstance(e, websockets.exceptions.ConnectionClosed):
|
|
568
|
+
if e.code == 1000: # Normal closure
|
|
569
|
+
logger.debug(f"WebSocket closed normally: {e}")
|
|
570
|
+
else:
|
|
571
|
+
logger.warning(
|
|
572
|
+
f"WebSocket closed with code {e.code}: {e}"
|
|
573
|
+
)
|
|
574
|
+
else:
|
|
575
|
+
logger.error(f"Error in receive loop: {e}")
|
|
370
576
|
# Notify all pending futures of the error
|
|
371
577
|
for future in self._pending_responses.values():
|
|
372
578
|
if not future.done():
|
|
@@ -379,16 +585,42 @@ class WebSocketBrowserWrapper:
|
|
|
379
585
|
async def _ensure_connection(self) -> None:
|
|
380
586
|
"""Ensure WebSocket connection is alive."""
|
|
381
587
|
if not self.websocket:
|
|
382
|
-
|
|
588
|
+
error_msg = "WebSocket not connected"
|
|
589
|
+
import psutil
|
|
590
|
+
|
|
591
|
+
mem = psutil.virtual_memory()
|
|
592
|
+
if mem.available < 1024**3:
|
|
593
|
+
error_msg = (
|
|
594
|
+
f"WebSocket not connected "
|
|
595
|
+
f"(likely due to insufficient memory). "
|
|
596
|
+
f"Available memory: {mem.available / 1024**3:.2f}GB "
|
|
597
|
+
f"({mem.percent}% used)"
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
raise RuntimeError(error_msg)
|
|
383
601
|
|
|
384
602
|
# Check if connection is still alive
|
|
385
603
|
try:
|
|
386
|
-
# Send a ping
|
|
387
|
-
await self.websocket.ping()
|
|
604
|
+
# Send a ping and wait for the corresponding pong (bounded wait)
|
|
605
|
+
pong_waiter = await self.websocket.ping()
|
|
606
|
+
await asyncio.wait_for(pong_waiter, timeout=5.0)
|
|
388
607
|
except Exception as e:
|
|
389
608
|
logger.warning(f"WebSocket ping failed: {e}")
|
|
390
609
|
self.websocket = None
|
|
391
|
-
|
|
610
|
+
|
|
611
|
+
error_msg = "WebSocket connection lost"
|
|
612
|
+
import psutil
|
|
613
|
+
|
|
614
|
+
mem = psutil.virtual_memory()
|
|
615
|
+
if mem.available < 1024**3:
|
|
616
|
+
error_msg = (
|
|
617
|
+
f"WebSocket connection lost "
|
|
618
|
+
f"(likely due to insufficient memory). "
|
|
619
|
+
f"Available memory: {mem.available / 1024**3:.2f}GB "
|
|
620
|
+
f"({mem.percent}% used)"
|
|
621
|
+
)
|
|
622
|
+
|
|
623
|
+
raise RuntimeError(error_msg)
|
|
392
624
|
|
|
393
625
|
async def _send_command(
|
|
394
626
|
self, command: str, params: Dict[str, Any]
|
|
@@ -403,7 +635,8 @@ class WebSocketBrowserWrapper:
|
|
|
403
635
|
message = {'id': message_id, 'command': command, 'params': params}
|
|
404
636
|
|
|
405
637
|
# Create a future for this message
|
|
406
|
-
|
|
638
|
+
loop = asyncio.get_running_loop()
|
|
639
|
+
future: asyncio.Future[Dict[str, Any]] = loop.create_future()
|
|
407
640
|
self._pending_responses[message_id] = future
|
|
408
641
|
|
|
409
642
|
try:
|
|
@@ -427,6 +660,16 @@ class WebSocketBrowserWrapper:
|
|
|
427
660
|
except asyncio.TimeoutError:
|
|
428
661
|
# Remove from pending if timeout
|
|
429
662
|
self._pending_responses.pop(message_id, None)
|
|
663
|
+
# Special handling for shutdown command
|
|
664
|
+
if command == 'shutdown':
|
|
665
|
+
logger.debug(
|
|
666
|
+
"Shutdown command timeout is expected - "
|
|
667
|
+
"server may have closed before responding"
|
|
668
|
+
)
|
|
669
|
+
# Return a success response for shutdown
|
|
670
|
+
return {
|
|
671
|
+
'message': 'Browser shutdown (no response received)'
|
|
672
|
+
}
|
|
430
673
|
raise RuntimeError(
|
|
431
674
|
f"Timeout waiting for response to command: {command}"
|
|
432
675
|
)
|
|
@@ -440,6 +683,12 @@ class WebSocketBrowserWrapper:
|
|
|
440
683
|
"close frame" in str(e)
|
|
441
684
|
or "connection closed" in str(e).lower()
|
|
442
685
|
):
|
|
686
|
+
# Special handling for shutdown command
|
|
687
|
+
if command == 'shutdown':
|
|
688
|
+
logger.debug(
|
|
689
|
+
f"Connection closed during shutdown (expected): {e}"
|
|
690
|
+
)
|
|
691
|
+
return {'message': 'Browser shutdown (connection closed)'}
|
|
443
692
|
logger.error(f"WebSocket connection closed unexpectedly: {e}")
|
|
444
693
|
# Mark connection as closed
|
|
445
694
|
self.websocket = None
|
|
@@ -565,6 +814,8 @@ class WebSocketBrowserWrapper:
|
|
|
565
814
|
async def type(self, ref: str, text: str) -> Dict[str, Any]:
|
|
566
815
|
"""Type text into an element."""
|
|
567
816
|
response = await self._send_command('type', {'ref': ref, 'text': text})
|
|
817
|
+
# Log the response for debugging
|
|
818
|
+
logger.debug(f"Type response for ref {ref}: {response}")
|
|
568
819
|
return response
|
|
569
820
|
|
|
570
821
|
@action_logger
|
|
@@ -681,3 +932,73 @@ class WebSocketBrowserWrapper:
|
|
|
681
932
|
'wait_user', {'timeout': timeout_sec}
|
|
682
933
|
)
|
|
683
934
|
return response
|
|
935
|
+
|
|
936
|
+
async def _read_and_log_output(self):
|
|
937
|
+
"""Read stdout from Node.js process & handle SERVER_READY + logging."""
|
|
938
|
+
if not self.process:
|
|
939
|
+
return
|
|
940
|
+
|
|
941
|
+
try:
|
|
942
|
+
with contextlib.ExitStack() as stack:
|
|
943
|
+
if self.ts_log_file_path:
|
|
944
|
+
self.ts_log_file = stack.enter_context(
|
|
945
|
+
open(self.ts_log_file_path, 'w', encoding='utf-8')
|
|
946
|
+
)
|
|
947
|
+
self.ts_log_file.write(
|
|
948
|
+
f"TypeScript Console Log - Started at "
|
|
949
|
+
f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
950
|
+
)
|
|
951
|
+
self.ts_log_file.write("=" * 80 + "\n")
|
|
952
|
+
self.ts_log_file.flush()
|
|
953
|
+
|
|
954
|
+
while self.process and self.process.poll() is None:
|
|
955
|
+
try:
|
|
956
|
+
line = (
|
|
957
|
+
await asyncio.get_running_loop().run_in_executor(
|
|
958
|
+
None, self.process.stdout.readline
|
|
959
|
+
)
|
|
960
|
+
)
|
|
961
|
+
if not line: # EOF
|
|
962
|
+
break
|
|
963
|
+
|
|
964
|
+
# Check for SERVER_READY message
|
|
965
|
+
if line.startswith('SERVER_READY:'):
|
|
966
|
+
try:
|
|
967
|
+
self.server_port = int(
|
|
968
|
+
line.split(':', 1)[1].strip()
|
|
969
|
+
)
|
|
970
|
+
logger.info(
|
|
971
|
+
f"WebSocket server ready on port "
|
|
972
|
+
f"{self.server_port}"
|
|
973
|
+
)
|
|
974
|
+
if (
|
|
975
|
+
self._server_ready_future
|
|
976
|
+
and not self._server_ready_future.done()
|
|
977
|
+
):
|
|
978
|
+
self._server_ready_future.set_result(True)
|
|
979
|
+
except (ValueError, IndexError) as e:
|
|
980
|
+
logger.error(
|
|
981
|
+
f"Failed to parse SERVER_READY: {e}"
|
|
982
|
+
)
|
|
983
|
+
|
|
984
|
+
# Write all output to log file
|
|
985
|
+
if self.ts_log_file:
|
|
986
|
+
timestamp = time.strftime('%H:%M:%S')
|
|
987
|
+
self.ts_log_file.write(f"[{timestamp}] {line}")
|
|
988
|
+
self.ts_log_file.flush()
|
|
989
|
+
|
|
990
|
+
except Exception as e:
|
|
991
|
+
logger.warning(f"Error reading stdout: {e}")
|
|
992
|
+
break
|
|
993
|
+
|
|
994
|
+
# Footer if we had a file
|
|
995
|
+
if self.ts_log_file:
|
|
996
|
+
self.ts_log_file.write("\n" + "=" * 80 + "\n")
|
|
997
|
+
self.ts_log_file.write(
|
|
998
|
+
f"TypeScript Console Log - Ended at "
|
|
999
|
+
f"{time.strftime('%Y-%m-%d %H:%M:%S')}\n"
|
|
1000
|
+
)
|
|
1001
|
+
# ExitStack closes file; clear handle
|
|
1002
|
+
self.ts_log_file = None
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
logger.warning(f"Error in _read_and_log_output: {e}")
|