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.
- camel/__init__.py +1 -1
- camel/agents/chat_agent.py +8 -1
- camel/memories/__init__.py +2 -1
- camel/memories/agent_memories.py +3 -1
- camel/memories/blocks/chat_history_block.py +17 -2
- camel/societies/workforce/single_agent_worker.py +44 -38
- camel/storages/object_storages/google_cloud.py +1 -1
- camel/toolkits/__init__.py +9 -2
- camel/toolkits/aci_toolkit.py +45 -0
- camel/toolkits/context_summarizer_toolkit.py +683 -0
- camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +4 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +7 -2
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +62 -45
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +489 -60
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +5 -2
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +72 -12
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +2 -14
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +196 -60
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +4 -4
- camel/toolkits/markitdown_toolkit.py +27 -1
- camel/toolkits/note_taking_toolkit.py +18 -8
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/utils/context_utils.py +395 -0
- {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/METADATA +2 -1
- {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/RECORD +30 -26
- {camel_ai-0.2.76a0.dist-info → camel_ai-0.2.76a1.dist-info}/WHEEL +0 -0
- {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(
|
|
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
|
-
|
|
30
|
-
const
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
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
|
|
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()
|
|
115
|
-
self._receive_task = None
|
|
116
|
-
self._pending_responses: Dict[
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
131
|
-
self._log_reader_task = None
|
|
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
|
-
|
|
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,
|
|
251
|
+
stderr=subprocess.STDOUT,
|
|
222
252
|
text=True,
|
|
223
|
-
bufsize=1,
|
|
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
|
|
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:
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|