camel-ai 0.2.76a0__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 (30) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +8 -1
  3. camel/memories/__init__.py +2 -1
  4. camel/memories/agent_memories.py +3 -1
  5. camel/memories/blocks/chat_history_block.py +17 -2
  6. camel/societies/workforce/single_agent_worker.py +44 -38
  7. camel/storages/object_storages/google_cloud.py +1 -1
  8. camel/toolkits/__init__.py +9 -2
  9. camel/toolkits/aci_toolkit.py +45 -0
  10. camel/toolkits/context_summarizer_toolkit.py +683 -0
  11. camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
  12. camel/toolkits/hybrid_browser_toolkit/config_loader.py +4 -0
  13. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +7 -2
  14. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +62 -45
  15. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +489 -60
  16. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +5 -2
  17. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +72 -12
  18. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +2 -14
  19. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
  20. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +196 -60
  21. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +4 -4
  22. camel/toolkits/markitdown_toolkit.py +27 -1
  23. camel/toolkits/note_taking_toolkit.py +18 -8
  24. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  25. camel/toolkits/wechat_official_toolkit.py +483 -0
  26. camel/utils/context_utils.py +395 -0
  27. {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/METADATA +2 -1
  28. {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/RECORD +30 -26
  29. {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/WHEEL +0 -0
  30. {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/licenses/LICENSE +0 -0
@@ -73,6 +73,7 @@ export interface BrowserConfig {
73
73
  // CDP connection options
74
74
  connectOverCdp: boolean;
75
75
  cdpUrl?: string;
76
+ cdpKeepCurrentPage: boolean;
76
77
  }
77
78
 
78
79
  export interface WebSocketConfig {
@@ -118,7 +119,7 @@ function getDefaultBrowserConfig(): BrowserConfig {
118
119
  consoleLogLimit: 1000,
119
120
  scrollPositionScale: 0.1,
120
121
  navigationDelay: 100,
121
- blankPageUrls: [],
122
+ blankPageUrls: ['chrome://newtab/', 'edge://newtab/', 'chrome://new-tab-page/'],
122
123
  dataUrlPrefix: 'data:',
123
124
  domContentLoadedState: 'domcontentloaded',
124
125
  networkIdleState: 'networkidle',
@@ -139,7 +140,8 @@ function getDefaultBrowserConfig(): BrowserConfig {
139
140
  height: 720
140
141
  },
141
142
  connectOverCdp: false,
142
- cdpUrl: undefined
143
+ cdpUrl: undefined,
144
+ cdpKeepCurrentPage: false
143
145
  };
144
146
  }
145
147
 
@@ -218,6 +220,7 @@ export class ConfigLoader {
218
220
  // CDP connection options
219
221
  if (config.connectOverCdp !== undefined) browserConfig.connectOverCdp = config.connectOverCdp;
220
222
  if (config.cdpUrl !== undefined) browserConfig.cdpUrl = config.cdpUrl;
223
+ if (config.cdpKeepCurrentPage !== undefined) browserConfig.cdpKeepCurrentPage = config.cdpKeepCurrentPage;
221
224
 
222
225
  return new ConfigLoader(browserConfig, wsConfig);
223
226
  }
@@ -15,7 +15,7 @@ export class HybridBrowserToolkit {
15
15
  constructor(config: BrowserToolkitConfig = {}) {
16
16
  this.configLoader = ConfigLoader.fromPythonConfig(config);
17
17
  this.config = config; // Store original config for backward compatibility
18
- this.session = new HybridBrowserSession(this.configLoader.getBrowserConfig()); // Pass processed config
18
+ this.session = new HybridBrowserSession(config); // Pass original config
19
19
  this.viewportLimit = this.configLoader.getWebSocketConfig().viewport_limit;
20
20
  this.fullVisualMode = this.configLoader.getWebSocketConfig().fullVisualMode || false;
21
21
  }
@@ -26,9 +26,54 @@ export class HybridBrowserToolkit {
26
26
  try {
27
27
  await this.session.ensureBrowser();
28
28
 
29
- const url = startUrl || this.config.defaultStartUrl || 'https://google.com/';
30
- const result = await this.session.visitPage(url);
29
+ // Check if we should skip navigation in CDP keep-current-page mode
30
+ const browserConfig = this.configLoader.getBrowserConfig();
31
+ if (browserConfig.cdpUrl && browserConfig.cdpKeepCurrentPage && !startUrl) {
32
+ // In CDP keep-current-page mode without explicit URL, just ensure browser and return current page
33
+ const snapshotStart = Date.now();
34
+ const snapshot = await this.getSnapshotForAction(this.viewportLimit);
35
+ const snapshotTime = Date.now() - snapshotStart;
36
+
37
+ const page = await this.session.getCurrentPage();
38
+ const currentUrl = page ? await page.url() : 'unknown';
39
+
40
+ const totalTime = Date.now() - startTime;
41
+
42
+ return {
43
+ success: true,
44
+ message: `Browser opened in CDP keep-current-page mode (current page: ${currentUrl})`,
45
+ snapshot,
46
+ timing: {
47
+ total_time_ms: totalTime,
48
+ snapshot_time_ms: snapshotTime,
49
+ },
50
+ };
51
+ }
31
52
 
53
+ // For normal mode or CDP with cdpKeepCurrentPage=false: navigate to URL
54
+ if (!browserConfig.cdpUrl || !browserConfig.cdpKeepCurrentPage) {
55
+ const url = startUrl || this.config.defaultStartUrl || 'https://google.com/';
56
+ const result = await this.session.visitPage(url);
57
+
58
+ const snapshotStart = Date.now();
59
+ const snapshot = await this.getSnapshotForAction(this.viewportLimit);
60
+ const snapshotTime = Date.now() - snapshotStart;
61
+
62
+ const totalTime = Date.now() - startTime;
63
+
64
+ return {
65
+ success: true,
66
+ message: result.message,
67
+ snapshot,
68
+ timing: {
69
+ total_time_ms: totalTime,
70
+ page_load_time_ms: result.timing?.page_load_time_ms || 0,
71
+ snapshot_time_ms: snapshotTime,
72
+ },
73
+ };
74
+ }
75
+
76
+ // Fallback: Just return current page snapshot without any navigation
32
77
  const snapshotStart = Date.now();
33
78
  const snapshot = await this.getSnapshotForAction(this.viewportLimit);
34
79
  const snapshotTime = Date.now() - snapshotStart;
@@ -37,11 +82,10 @@ export class HybridBrowserToolkit {
37
82
 
38
83
  return {
39
84
  success: true,
40
- message: `Browser opened and navigated to ${url}`,
85
+ message: `Browser opened without navigation`,
41
86
  snapshot,
42
87
  timing: {
43
88
  total_time_ms: totalTime,
44
- ...result.timing,
45
89
  snapshot_time_ms: snapshotTime,
46
90
  },
47
91
  };
@@ -216,19 +260,27 @@ export class HybridBrowserToolkit {
216
260
  private async executeActionWithSnapshot(action: BrowserAction): Promise<any> {
217
261
  const result = await this.session.executeAction(action);
218
262
 
219
- // Format response for Python layer compatibility
220
263
  const response: any = {
221
264
  result: result.message,
222
265
  snapshot: '',
223
266
  };
224
267
 
225
268
  if (result.success) {
226
- const snapshotStart = Date.now();
227
- response.snapshot = await this.getPageSnapshot(this.viewportLimit);
228
- const snapshotTime = Date.now() - snapshotStart;
229
-
230
- if (result.timing) {
231
- result.timing.snapshot_time_ms = snapshotTime;
269
+ if (result.details?.diffSnapshot) {
270
+ response.snapshot = result.details.diffSnapshot;
271
+
272
+ if (result.timing) {
273
+ result.timing.snapshot_time_ms = 0; // Diff snapshot time is included in action time
274
+ }
275
+ } else {
276
+ // Get full snapshot as usual
277
+ const snapshotStart = Date.now();
278
+ response.snapshot = await this.getPageSnapshot(this.viewportLimit);
279
+ const snapshotTime = Date.now() - snapshotStart;
280
+
281
+ if (result.timing) {
282
+ result.timing.snapshot_time_ms = snapshotTime;
283
+ }
232
284
  }
233
285
  }
234
286
 
@@ -242,6 +294,14 @@ export class HybridBrowserToolkit {
242
294
  response.newTabId = result.newTabId;
243
295
  }
244
296
 
297
+ // Include details if present (excluding diffSnapshot as it's already in snapshot)
298
+ if (result.details) {
299
+ const { diffSnapshot, ...otherDetails } = result.details;
300
+ if (Object.keys(otherDetails).length > 0) {
301
+ response.details = otherDetails;
302
+ }
303
+ }
304
+
245
305
  return response;
246
306
  }
247
307
 
@@ -21,26 +21,19 @@ export function parseSnapshotHierarchy(snapshotText: string): Map<string, Snapsh
21
21
  for (const line of lines) {
22
22
  if (!line.trim()) continue;
23
23
 
24
- // Calculate indentation
25
24
  const indent = line.length - line.trimStart().length;
26
25
 
27
- // Extract element info using regex
28
- // Support both lines with : (have children) and without : (leaf nodes)
29
- // Also support quoted lines like - 'button "text" [ref=...]'
30
- // Also support escaped quotes in text
31
- // Also support attributes before [ref=...]
32
- // Extract type and optional label (before any [..] blocks)
26
+ // Extract type and optional label
33
27
  const headerMatch = line.match(/^\s*(?:-\s*)?'?([a-z0-9_-]+)(?:\s+"((?:[^"\\]|\\.)*)")?/i);
34
28
  if (!headerMatch) continue;
35
29
  const [, typeRaw, label] = headerMatch;
36
30
  const type = (typeRaw || 'unknown');
37
31
 
38
- // Extract mandatory ref
39
32
  const refMatch = line.match(/\[ref=([^\]]+)\]/i);
40
33
  if (!refMatch) continue;
41
34
  const ref = refMatch[1];
42
35
 
43
- // Parse all bracketed attributes except the [ref=...] block
36
+ // Parse bracketed attributes
44
37
  const attrs: Record<string, string> = {};
45
38
  for (const block of line.matchAll(/\[([^\]]+)\]/g)) {
46
39
  const content = block[1];
@@ -53,12 +46,10 @@ export function parseSnapshotHierarchy(snapshotText: string): Map<string, Snapsh
53
46
  }
54
47
  }
55
48
 
56
- // Update parent stack based on indentation
57
49
  while (parentStack.length > 0 && parentStack[parentStack.length - 1].indent >= indent) {
58
50
  parentStack.pop();
59
51
  }
60
52
 
61
- // Create node
62
53
  const node: SnapshotNode = {
63
54
  ref,
64
55
  type: type.toLowerCase(),
@@ -68,15 +59,12 @@ export function parseSnapshotHierarchy(snapshotText: string): Map<string, Snapsh
68
59
  parent: parentStack.length > 0 ? parentStack[parentStack.length - 1].ref : undefined
69
60
  };
70
61
 
71
- // Add to parent's children if has parent
72
62
  if (node.parent && nodes.has(node.parent)) {
73
63
  nodes.get(node.parent)!.children.push(ref);
74
64
  }
75
65
 
76
- // Add to nodes map
77
66
  nodes.set(ref, node);
78
67
 
79
- // Add to parent stack
80
68
  parentStack.push({ ref, indent });
81
69
  }
82
70
 
@@ -72,6 +72,7 @@ export interface BrowserToolkitConfig {
72
72
  useNativePlaywrightMapping?: boolean; // New option to control mapping implementation
73
73
  connectOverCdp?: boolean; // Whether to connect to existing browser via CDP
74
74
  cdpUrl?: string; // WebSocket endpoint URL for CDP connection
75
+ cdpKeepCurrentPage?: boolean; // When true, CDP mode will keep the current page instead of creating new one
75
76
  }
76
77
 
77
78
  export interface ClickAction {
@@ -45,23 +45,19 @@ def action_logger(func):
45
45
  action_name = func.__name__
46
46
  start_time = time.time()
47
47
 
48
- # Log inputs (skip self)
49
48
  inputs = {
50
49
  "args": args,
51
50
  "kwargs": kwargs,
52
51
  }
53
52
 
54
53
  try:
55
- # Execute the original function
56
54
  result = await func(self, *args, **kwargs)
57
55
  execution_time = time.time() - start_time
58
56
 
59
- # Extract page load time if available
60
57
  page_load_time = None
61
58
  if isinstance(result, dict) and 'page_load_time_ms' in result:
62
59
  page_load_time = result['page_load_time_ms'] / 1000.0
63
60
 
64
- # Log success
65
61
  await self._log_action(
66
62
  action_name=action_name,
67
63
  inputs=inputs,
@@ -76,7 +72,6 @@ def action_logger(func):
76
72
  execution_time = time.time() - start_time
77
73
  error_msg = f"{type(e).__name__}: {e!s}"
78
74
 
79
- # Log error
80
75
  await self._log_action(
81
76
  action_name=action_name,
82
77
  inputs=inputs,
@@ -111,14 +106,11 @@ class WebSocketBrowserWrapper:
111
106
  self.process: Optional[subprocess.Popen] = None
112
107
  self.websocket = None
113
108
  self.server_port = None
114
- self._send_lock = asyncio.Lock() # Lock for sending messages
115
- self._receive_task = None # Background task for receiving messages
116
- self._pending_responses: Dict[
117
- str, asyncio.Future[Dict[str, Any]]
118
- ] = {} # Message ID -> Future
119
- self._server_ready_future = None # Future to track server ready state
120
-
121
- # Logging configuration
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
113
+
122
114
  self.browser_log_to_file = (config or {}).get(
123
115
  'browser_log_to_file', False
124
116
  )
@@ -127,10 +119,9 @@ class WebSocketBrowserWrapper:
127
119
  self.log_file_path: Optional[str] = None
128
120
  self.log_buffer: List[Dict[str, Any]] = []
129
121
  self.ts_log_file_path: Optional[str] = None
130
- self.ts_log_file = None # File handle for TypeScript logs
131
- self._log_reader_task = None # Task for reading and logging stdout
122
+ self.ts_log_file = None
123
+ self._log_reader_task = None
132
124
 
133
- # Set up log files if needed
134
125
  if self.browser_log_to_file:
135
126
  log_dir = self.log_dir if self.log_dir else "browser_log"
136
127
  os.makedirs(log_dir, exist_ok=True)
@@ -139,7 +130,6 @@ class WebSocketBrowserWrapper:
139
130
  log_dir,
140
131
  f"hybrid_browser_toolkit_ws_{timestamp}_{self.session_id}.log",
141
132
  )
142
- # Add TypeScript console log file
143
133
  self.ts_log_file_path = os.path.join(
144
134
  log_dir,
145
135
  f"typescript_console_{timestamp}_{self.session_id}.log",
@@ -154,9 +144,53 @@ class WebSocketBrowserWrapper:
154
144
  """Async context manager exit."""
155
145
  await self.stop()
156
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
+
157
190
  async def start(self):
158
191
  """Start the WebSocket server and connect to it."""
159
- # Check if npm is installed
192
+ await self._cleanup_existing_processes()
193
+
160
194
  npm_check = subprocess.run(
161
195
  ['npm', '--version'],
162
196
  capture_output=True,
@@ -169,7 +203,6 @@ class WebSocketBrowserWrapper:
169
203
  "to use the hybrid browser toolkit."
170
204
  )
171
205
 
172
- # Check if node is installed
173
206
  node_check = subprocess.run(
174
207
  ['node', '--version'],
175
208
  capture_output=True,
@@ -182,7 +215,6 @@ class WebSocketBrowserWrapper:
182
215
  "to use the hybrid browser toolkit."
183
216
  )
184
217
 
185
- # Check if node_modules exists (dependencies installed)
186
218
  node_modules_path = os.path.join(self.ts_dir, 'node_modules')
187
219
  if not os.path.exists(node_modules_path):
188
220
  logger.warning("Node modules not found. Running npm install...")
@@ -200,7 +232,6 @@ class WebSocketBrowserWrapper:
200
232
  )
201
233
  logger.info("npm dependencies installed successfully")
202
234
 
203
- # Ensure the TypeScript code is built
204
235
  build_result = subprocess.run(
205
236
  ['npm', 'run', 'build'],
206
237
  cwd=self.ts_dir,
@@ -213,20 +244,17 @@ class WebSocketBrowserWrapper:
213
244
  f"TypeScript build failed: {build_result.stderr}"
214
245
  )
215
246
 
216
- # Start the WebSocket server
217
247
  self.process = subprocess.Popen(
218
248
  ['node', 'websocket-server.js'],
219
249
  cwd=self.ts_dir,
220
250
  stdout=subprocess.PIPE,
221
- stderr=subprocess.STDOUT, # Redirect stderr to stdout
251
+ stderr=subprocess.STDOUT,
222
252
  text=True,
223
- bufsize=1, # Line buffered
253
+ bufsize=1,
224
254
  )
225
255
 
226
- # Create a future to wait for server ready (before starting log reader)
227
256
  self._server_ready_future = asyncio.get_running_loop().create_future()
228
257
 
229
- # Start log reader task immediately after process starts
230
258
  self._log_reader_task = asyncio.create_task(
231
259
  self._read_and_log_output()
232
260
  )
@@ -237,11 +265,9 @@ class WebSocketBrowserWrapper:
237
265
  f"{self.ts_log_file_path}"
238
266
  )
239
267
 
240
- # Wait for server to output the port
241
268
  server_ready = False
242
- timeout = 10 # 10 seconds timeout
269
+ timeout = 10
243
270
 
244
- # Wait for the server to be ready
245
271
  try:
246
272
  await asyncio.wait_for(self._server_ready_future, timeout=timeout)
247
273
  server_ready = True
@@ -252,14 +278,11 @@ class WebSocketBrowserWrapper:
252
278
  with contextlib.suppress(ProcessLookupError, Exception):
253
279
  self.process.kill()
254
280
  with contextlib.suppress(Exception):
255
- # Ensure the process fully exits
256
281
  self.process.wait(timeout=2)
257
- # Cancel and await the log reader task
258
282
  if self._log_reader_task and not self._log_reader_task.done():
259
283
  self._log_reader_task.cancel()
260
284
  with contextlib.suppress(asyncio.CancelledError):
261
285
  await self._log_reader_task
262
- # Close TS log file if open
263
286
  if getattr(self, 'ts_log_file', None):
264
287
  with contextlib.suppress(Exception):
265
288
  self.ts_log_file.close()
@@ -270,7 +293,7 @@ class WebSocketBrowserWrapper:
270
293
  import psutil
271
294
 
272
295
  mem = psutil.virtual_memory()
273
- if mem.available < 1024**3: # Less than 1GB available
296
+ if mem.available < 1024**3:
274
297
  error_msg = (
275
298
  f"WebSocket server failed to start"
276
299
  f"(likely due to insufficient memory). "
@@ -280,16 +303,59 @@ class WebSocketBrowserWrapper:
280
303
 
281
304
  raise RuntimeError(error_msg)
282
305
 
283
- # Connect to the WebSocket server
284
- try:
285
- self.websocket = await websockets.connect(
286
- f"ws://localhost:{self.server_port}",
287
- ping_interval=30,
288
- ping_timeout=10,
289
- max_size=50 * 1024 * 1024, # 50MB limit to match server
290
- )
291
- logger.info("Connected to WebSocket server")
292
- except Exception as e:
306
+ max_retries = 3
307
+ retry_delays = [1, 2, 4]
308
+
309
+ for attempt in range(max_retries):
310
+ try:
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"
344
+ )
345
+
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
357
+
358
+ if not self.websocket:
293
359
  with contextlib.suppress(ProcessLookupError, Exception):
294
360
  self.process.kill()
295
361
  with contextlib.suppress(Exception):
@@ -304,39 +370,35 @@ class WebSocketBrowserWrapper:
304
370
  self.ts_log_file = None
305
371
  self.process = None
306
372
 
307
- error_msg = f"Failed to connect to WebSocket server: {e}"
308
373
  import psutil
309
374
 
310
375
  mem = psutil.virtual_memory()
311
- if mem.available < 1024**3: # Less than 1GB available
376
+
377
+ error_msg = (
378
+ "Failed to connect to WebSocket server after multiple attempts"
379
+ )
380
+ if mem.available < 1024**3:
312
381
  error_msg = (
313
- f"Failed to connect to WebSocket server"
382
+ f"Failed to connect to WebSocket server "
314
383
  f"(likely due to insufficient memory). "
315
- f"Available memory: {mem.available / 1024**3:.2f}GB"
316
- f"({mem.percent}% used). "
317
- f"Original error: {e}"
384
+ f"Available memory: {mem.available / 1024**3:.2f}GB "
385
+ f"({mem.percent}% used)"
318
386
  )
319
387
 
320
- raise RuntimeError(error_msg) from e
388
+ raise RuntimeError(error_msg)
321
389
 
322
- # Start the background receiver task
323
390
  self._receive_task = asyncio.create_task(self._receive_loop())
324
391
 
325
- # Initialize the browser toolkit
326
392
  await self._send_command('init', self.config)
327
393
 
328
394
  async def stop(self):
329
395
  """Stop the WebSocket connection and server."""
330
- # First, send shutdown command while receive task is still running
331
396
  if self.websocket:
332
397
  with contextlib.suppress(asyncio.TimeoutError, Exception):
333
- # Send shutdown command with a short timeout
334
398
  await asyncio.wait_for(
335
399
  self._send_command('shutdown', {}),
336
- timeout=2.0, # 2 second timeout for shutdown
400
+ timeout=2.0,
337
401
  )
338
- # Note: TimeoutError is expected as server may close
339
- # before responding
340
402
 
341
403
  # Close websocket connection
342
404
  with contextlib.suppress(Exception):
@@ -381,6 +443,53 @@ class WebSocketBrowserWrapper:
381
443
  # Ensure process handle cleared
382
444
  self.process = None
383
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
456
+
457
+ # Stop the Node process
458
+ if self.process:
459
+ try:
460
+ # Send SIGTERM to gracefully shutdown
461
+ self.process.terminate()
462
+ self.process.wait(timeout=3)
463
+ except subprocess.TimeoutExpired:
464
+ # Force kill if needed
465
+ with contextlib.suppress(ProcessLookupError, Exception):
466
+ self.process.kill()
467
+ self.process.wait()
468
+ except Exception as e:
469
+ logger.warning(f"Error terminating process: {e}")
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")
492
+
384
493
  async def _log_action(
385
494
  self,
386
495
  action_name: str,
@@ -454,7 +563,16 @@ class WebSocketBrowserWrapper:
454
563
  except asyncio.CancelledError:
455
564
  break
456
565
  except Exception as e:
457
- 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}")
458
576
  # Notify all pending futures of the error
459
577
  for future in self._pending_responses.values():
460
578
  if not future.done():
@@ -471,7 +589,7 @@ class WebSocketBrowserWrapper:
471
589
  import psutil
472
590
 
473
591
  mem = psutil.virtual_memory()
474
- if mem.available < 1024**3: # Less than 1GB available
592
+ if mem.available < 1024**3:
475
593
  error_msg = (
476
594
  f"WebSocket not connected "
477
595
  f"(likely due to insufficient memory). "
@@ -494,7 +612,7 @@ class WebSocketBrowserWrapper:
494
612
  import psutil
495
613
 
496
614
  mem = psutil.virtual_memory()
497
- if mem.available < 1024**3: # Less than 1GB available
615
+ if mem.available < 1024**3:
498
616
  error_msg = (
499
617
  f"WebSocket connection lost "
500
618
  f"(likely due to insufficient memory). "
@@ -542,6 +660,16 @@ class WebSocketBrowserWrapper:
542
660
  except asyncio.TimeoutError:
543
661
  # Remove from pending if timeout
544
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
+ }
545
673
  raise RuntimeError(
546
674
  f"Timeout waiting for response to command: {command}"
547
675
  )
@@ -555,6 +683,12 @@ class WebSocketBrowserWrapper:
555
683
  "close frame" in str(e)
556
684
  or "connection closed" in str(e).lower()
557
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)'}
558
692
  logger.error(f"WebSocket connection closed unexpectedly: {e}")
559
693
  # Mark connection as closed
560
694
  self.websocket = None
@@ -680,6 +814,8 @@ class WebSocketBrowserWrapper:
680
814
  async def type(self, ref: str, text: str) -> Dict[str, Any]:
681
815
  """Type text into an element."""
682
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}")
683
819
  return response
684
820
 
685
821
  @action_logger