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.

Files changed (52) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +159 -38
  3. camel/configs/__init__.py +3 -0
  4. camel/configs/amd_config.py +70 -0
  5. camel/interpreters/__init__.py +2 -0
  6. camel/interpreters/microsandbox_interpreter.py +395 -0
  7. camel/memories/__init__.py +2 -1
  8. camel/memories/agent_memories.py +3 -1
  9. camel/memories/blocks/chat_history_block.py +17 -2
  10. camel/models/__init__.py +2 -0
  11. camel/models/amd_model.py +101 -0
  12. camel/models/model_factory.py +2 -0
  13. camel/models/openai_model.py +0 -6
  14. camel/runtimes/daytona_runtime.py +11 -12
  15. camel/societies/workforce/single_agent_worker.py +44 -38
  16. camel/storages/object_storages/google_cloud.py +1 -1
  17. camel/toolkits/__init__.py +14 -5
  18. camel/toolkits/aci_toolkit.py +45 -0
  19. camel/toolkits/code_execution.py +28 -1
  20. camel/toolkits/context_summarizer_toolkit.py +683 -0
  21. camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
  22. camel/toolkits/function_tool.py +6 -1
  23. camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
  24. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +19 -2
  25. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
  26. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
  27. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
  28. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
  29. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  30. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  31. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  32. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
  33. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
  34. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +401 -80
  35. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
  36. camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
  37. camel/toolkits/markitdown_toolkit.py +27 -1
  38. camel/toolkits/mcp_toolkit.py +39 -14
  39. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  40. camel/toolkits/note_taking_toolkit.py +18 -8
  41. camel/toolkits/terminal_toolkit.py +12 -2
  42. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  43. camel/toolkits/video_analysis_toolkit.py +16 -10
  44. camel/toolkits/wechat_official_toolkit.py +483 -0
  45. camel/types/enums.py +11 -0
  46. camel/utils/commons.py +2 -0
  47. camel/utils/context_utils.py +395 -0
  48. camel/utils/mcp.py +136 -2
  49. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.dist-info}/METADATA +6 -3
  50. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.dist-info}/RECORD +52 -41
  51. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76a1.dist-info}/WHEEL +0 -0
  52. {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() # Lock for sending messages
114
- self._receive_task = None # Background task for receiving messages
115
- self._pending_responses: Dict[
116
- str, asyncio.Future[Dict[str, Any]]
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
- # Check if npm is installed
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.PIPE,
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
- # Wait for server to output the port
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 # 10 seconds timeout
217
- start_time = time.time()
269
+ timeout = 10
218
270
 
219
- while not server_ready and time.time() - start_time < timeout:
220
- if self.process.poll() is not None:
221
- # Process died
222
- stderr = self.process.stderr.read()
223
- raise RuntimeError(
224
- f"WebSocket server failed to start: {stderr}"
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
- line = self.process.stdout.readline()
229
- if line.startswith('SERVER_READY:'):
230
- self.server_port = int(line.split(':')[1].strip())
231
- server_ready = True
232
- logger.info(
233
- f"WebSocket server ready on port {self.server_port}"
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
- if not server_ready:
239
- self.process.kill()
240
- raise RuntimeError(
241
- "WebSocket server failed to start within timeout"
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
- # Connect to the WebSocket server
245
- try:
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
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
- logger.info("Connected to WebSocket server")
253
- except Exception as e:
254
- self.process.kill()
255
- raise RuntimeError(
256
- f"Failed to connect to WebSocket server: {e}"
257
- ) from e
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
- try:
277
- await self._send_command('shutdown', {})
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 during websocket shutdown: {e}")
281
- finally:
282
- self.websocket = None
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=5)
462
+ self.process.wait(timeout=3)
288
463
  except subprocess.TimeoutExpired:
289
- self.process.kill()
290
- self.process.wait()
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
- finally:
294
- self.process = None
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
- logger.error(f"Error in receive loop: {e}")
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
- raise RuntimeError("WebSocket not connected")
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 to check connection
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
- raise RuntimeError("WebSocket connection lost")
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
- future: asyncio.Future[Dict[str, Any]] = asyncio.Future()
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}")