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.

Files changed (97) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +1001 -205
  3. camel/agents/mcp_agent.py +30 -27
  4. camel/configs/__init__.py +6 -0
  5. camel/configs/amd_config.py +70 -0
  6. camel/configs/cometapi_config.py +104 -0
  7. camel/data_collectors/alpaca_collector.py +15 -6
  8. camel/environments/tic_tac_toe.py +1 -1
  9. camel/interpreters/__init__.py +2 -0
  10. camel/interpreters/docker/Dockerfile +3 -12
  11. camel/interpreters/microsandbox_interpreter.py +395 -0
  12. camel/loaders/__init__.py +11 -2
  13. camel/loaders/chunkr_reader.py +9 -0
  14. camel/memories/__init__.py +2 -1
  15. camel/memories/agent_memories.py +3 -1
  16. camel/memories/blocks/chat_history_block.py +21 -3
  17. camel/memories/records.py +88 -8
  18. camel/messages/base.py +127 -34
  19. camel/models/__init__.py +4 -0
  20. camel/models/amd_model.py +101 -0
  21. camel/models/azure_openai_model.py +0 -6
  22. camel/models/base_model.py +30 -0
  23. camel/models/cometapi_model.py +83 -0
  24. camel/models/model_factory.py +4 -0
  25. camel/models/openai_compatible_model.py +0 -6
  26. camel/models/openai_model.py +0 -6
  27. camel/models/zhipuai_model.py +61 -2
  28. camel/parsers/__init__.py +18 -0
  29. camel/parsers/mcp_tool_call_parser.py +176 -0
  30. camel/retrievers/auto_retriever.py +1 -0
  31. camel/runtimes/daytona_runtime.py +11 -12
  32. camel/societies/workforce/prompts.py +131 -50
  33. camel/societies/workforce/single_agent_worker.py +434 -49
  34. camel/societies/workforce/structured_output_handler.py +30 -18
  35. camel/societies/workforce/task_channel.py +43 -0
  36. camel/societies/workforce/utils.py +105 -12
  37. camel/societies/workforce/workforce.py +1322 -311
  38. camel/societies/workforce/workforce_logger.py +24 -5
  39. camel/storages/key_value_storages/json.py +15 -2
  40. camel/storages/object_storages/google_cloud.py +1 -1
  41. camel/storages/vectordb_storages/oceanbase.py +10 -11
  42. camel/storages/vectordb_storages/tidb.py +8 -6
  43. camel/tasks/task.py +4 -3
  44. camel/toolkits/__init__.py +18 -5
  45. camel/toolkits/aci_toolkit.py +45 -0
  46. camel/toolkits/code_execution.py +28 -1
  47. camel/toolkits/context_summarizer_toolkit.py +684 -0
  48. camel/toolkits/dingtalk.py +1135 -0
  49. camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
  50. camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
  51. camel/toolkits/function_tool.py +6 -1
  52. camel/toolkits/google_drive_mcp_toolkit.py +12 -31
  53. camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
  54. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +79 -2
  55. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
  56. camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
  57. camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
  58. camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
  59. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
  60. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
  61. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
  62. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  63. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  64. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  65. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
  66. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
  67. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +405 -131
  68. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
  69. camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
  70. camel/toolkits/markitdown_toolkit.py +27 -1
  71. camel/toolkits/mcp_toolkit.py +348 -348
  72. camel/toolkits/message_integration.py +3 -0
  73. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  74. camel/toolkits/note_taking_toolkit.py +18 -8
  75. camel/toolkits/notion_mcp_toolkit.py +16 -26
  76. camel/toolkits/origene_mcp_toolkit.py +8 -49
  77. camel/toolkits/playwright_mcp_toolkit.py +12 -31
  78. camel/toolkits/resend_toolkit.py +168 -0
  79. camel/toolkits/slack_toolkit.py +50 -1
  80. camel/toolkits/terminal_toolkit/__init__.py +18 -0
  81. camel/toolkits/terminal_toolkit/terminal_toolkit.py +924 -0
  82. camel/toolkits/terminal_toolkit/utils.py +532 -0
  83. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  84. camel/toolkits/video_analysis_toolkit.py +17 -11
  85. camel/toolkits/wechat_official_toolkit.py +483 -0
  86. camel/types/enums.py +124 -1
  87. camel/types/unified_model_type.py +5 -0
  88. camel/utils/commons.py +17 -0
  89. camel/utils/context_utils.py +804 -0
  90. camel/utils/mcp.py +136 -2
  91. camel/utils/token_counting.py +25 -17
  92. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/METADATA +158 -59
  93. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/RECORD +95 -76
  94. camel/loaders/pandas_reader.py +0 -368
  95. camel/toolkits/terminal_toolkit.py +0 -1788
  96. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/WHEEL +0 -0
  97. {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() # 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
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
- # Check if npm is installed
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
- # Check if node is installed
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
- # Check if node_modules exists (dependencies installed)
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
- # Ensure the TypeScript code is built
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
- ['node', 'websocket-server.js'],
235
+ [node_cmd, 'websocket-server.js'],
208
236
  cwd=self.ts_dir,
209
237
  stdout=subprocess.PIPE,
210
- stderr=subprocess.PIPE,
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
- # Wait for server to output the port
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 # 10 seconds timeout
217
- start_time = time.time()
258
+ timeout = 10
218
259
 
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}"
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
- 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}"
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
- if not server_ready:
239
- self.process.kill()
240
- raise RuntimeError(
241
- "WebSocket server failed to start within timeout"
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
- # 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
341
+ error_msg = _create_memory_aware_error(
342
+ "Failed to connect to WebSocket server after multiple attempts"
251
343
  )
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
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
- try:
277
- await self._send_command('shutdown', {})
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 during websocket shutdown: {e}")
281
- finally:
282
- self.websocket = None
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=5)
423
+ self.process.wait(timeout=3)
288
424
  except subprocess.TimeoutExpired:
289
- self.process.kill()
290
- self.process.wait()
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
- finally:
294
- self.process = None
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
- logger.error(f"Error in receive loop: {e}")
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
- raise RuntimeError("WebSocket not connected")
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 to check connection
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
- raise RuntimeError("WebSocket connection lost")
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
- future: asyncio.Future[Dict[str, Any]] = asyncio.Future()
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}")