kolega-code 0.1.0__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.
Files changed (171) hide show
  1. kolega_code/__init__.py +151 -0
  2. kolega_code/agent/__init__.py +42 -0
  3. kolega_code/agent/baseagent.py +998 -0
  4. kolega_code/agent/browseragent.py +123 -0
  5. kolega_code/agent/coder.py +157 -0
  6. kolega_code/agent/common.py +41 -0
  7. kolega_code/agent/compression.py +81 -0
  8. kolega_code/agent/context.py +112 -0
  9. kolega_code/agent/conversation.py +408 -0
  10. kolega_code/agent/generalagent.py +146 -0
  11. kolega_code/agent/investigationagent.py +123 -0
  12. kolega_code/agent/planningagent.py +187 -0
  13. kolega_code/agent/prompt_provider.py +196 -0
  14. kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
  15. kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
  16. kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
  17. kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
  18. kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
  19. kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
  20. kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
  21. kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
  22. kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
  23. kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
  24. kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
  25. kolega_code/agent/prompts.py +192 -0
  26. kolega_code/agent/tests/__init__.py +0 -0
  27. kolega_code/agent/tests/llm/__init__.py +0 -0
  28. kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
  29. kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
  30. kolega_code/agent/tests/llm/test_client.py +773 -0
  31. kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
  32. kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
  33. kolega_code/agent/tests/llm/test_exceptions.py +249 -0
  34. kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
  35. kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
  36. kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
  37. kolega_code/agent/tests/llm/test_model_specs.py +17 -0
  38. kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
  39. kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
  40. kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
  41. kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
  42. kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
  43. kolega_code/agent/tests/services/__init__.py +1 -0
  44. kolega_code/agent/tests/services/test_browser.py +447 -0
  45. kolega_code/agent/tests/services/test_browser_parity.py +353 -0
  46. kolega_code/agent/tests/services/test_file_system.py +699 -0
  47. kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
  48. kolega_code/agent/tests/services/test_terminal.py +154 -0
  49. kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
  50. kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
  51. kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
  52. kolega_code/agent/tests/test_base_agent.py +1942 -0
  53. kolega_code/agent/tests/test_coder_attachments.py +330 -0
  54. kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
  55. kolega_code/agent/tests/test_commands.py +179 -0
  56. kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
  57. kolega_code/agent/tests/test_empty_message_handling.py +48 -0
  58. kolega_code/agent/tests/test_general_agent.py +242 -0
  59. kolega_code/agent/tests/test_html.py +320 -0
  60. kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
  61. kolega_code/agent/tests/test_planning_agent.py +227 -0
  62. kolega_code/agent/tests/test_prompt_provider.py +271 -0
  63. kolega_code/agent/tests/test_tool_registry.py +102 -0
  64. kolega_code/agent/tests/test_tools.py +549 -0
  65. kolega_code/agent/tests/tool_backend/__init__.py +0 -0
  66. kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
  67. kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
  68. kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
  69. kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
  70. kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
  71. kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
  72. kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
  73. kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
  74. kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
  75. kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
  76. kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
  77. kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
  78. kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
  79. kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
  80. kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
  81. kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
  82. kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
  83. kolega_code/agent/tool_backend/agent_tool.py +414 -0
  84. kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
  85. kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
  86. kolega_code/agent/tool_backend/base_tool.py +217 -0
  87. kolega_code/agent/tool_backend/browser_tool.py +271 -0
  88. kolega_code/agent/tool_backend/build_tool.py +93 -0
  89. kolega_code/agent/tool_backend/create_file_tool.py +52 -0
  90. kolega_code/agent/tool_backend/glob_tool.py +323 -0
  91. kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
  92. kolega_code/agent/tool_backend/memory_tool.py +79 -0
  93. kolega_code/agent/tool_backend/read_file_tool.py +119 -0
  94. kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
  95. kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
  96. kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
  97. kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
  98. kolega_code/agent/tool_backend/streaming_tool.py +47 -0
  99. kolega_code/agent/tool_backend/terminal_tool.py +643 -0
  100. kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
  101. kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
  102. kolega_code/agent/tools.py +1704 -0
  103. kolega_code/agent/utils/commands.py +94 -0
  104. kolega_code/cli/__init__.py +1 -0
  105. kolega_code/cli/app.py +2756 -0
  106. kolega_code/cli/config.py +280 -0
  107. kolega_code/cli/connection.py +49 -0
  108. kolega_code/cli/file_index.py +147 -0
  109. kolega_code/cli/main.py +564 -0
  110. kolega_code/cli/mentions.py +155 -0
  111. kolega_code/cli/messages.py +89 -0
  112. kolega_code/cli/provider_registry.py +96 -0
  113. kolega_code/cli/session_store.py +207 -0
  114. kolega_code/cli/settings.py +87 -0
  115. kolega_code/cli/skills.py +409 -0
  116. kolega_code/cli/slash_commands.py +108 -0
  117. kolega_code/cli/tests/__init__.py +1 -0
  118. kolega_code/cli/tests/test_app.py +4251 -0
  119. kolega_code/cli/tests/test_cli_config.py +171 -0
  120. kolega_code/cli/tests/test_connection.py +26 -0
  121. kolega_code/cli/tests/test_file_index.py +103 -0
  122. kolega_code/cli/tests/test_main.py +455 -0
  123. kolega_code/cli/tests/test_mentions.py +108 -0
  124. kolega_code/cli/tests/test_session_store.py +67 -0
  125. kolega_code/cli/tests/test_settings.py +62 -0
  126. kolega_code/cli/tests/test_skills.py +157 -0
  127. kolega_code/cli/tests/test_slash_commands.py +88 -0
  128. kolega_code/cli/theme.py +180 -0
  129. kolega_code/config.py +154 -0
  130. kolega_code/events.py +202 -0
  131. kolega_code/llm/client.py +300 -0
  132. kolega_code/llm/exceptions.py +285 -0
  133. kolega_code/llm/instrumented_client.py +520 -0
  134. kolega_code/llm/models.py +1368 -0
  135. kolega_code/llm/providers/__init__.py +0 -0
  136. kolega_code/llm/providers/anthropic.py +387 -0
  137. kolega_code/llm/providers/base.py +71 -0
  138. kolega_code/llm/providers/google.py +157 -0
  139. kolega_code/llm/providers/models.py +37 -0
  140. kolega_code/llm/providers/openai.py +363 -0
  141. kolega_code/llm/ratelimit.py +40 -0
  142. kolega_code/llm/specs.py +67 -0
  143. kolega_code/llm/tool_execution_ids.py +18 -0
  144. kolega_code/models/__init__.py +9 -0
  145. kolega_code/models/sandbox_terminal_state.py +47 -0
  146. kolega_code/runtime.py +50 -0
  147. kolega_code/sandbox/README.md +200 -0
  148. kolega_code/sandbox/__init__.py +21 -0
  149. kolega_code/sandbox/async_filesystem.py +475 -0
  150. kolega_code/sandbox/base.py +297 -0
  151. kolega_code/sandbox/browser.py +25 -0
  152. kolega_code/sandbox/event_loop.py +43 -0
  153. kolega_code/sandbox/filesystem.py +341 -0
  154. kolega_code/sandbox/local.py +118 -0
  155. kolega_code/sandbox/serializer.py +175 -0
  156. kolega_code/sandbox/terminal.py +868 -0
  157. kolega_code/sandbox/utils.py +216 -0
  158. kolega_code/services/base.py +255 -0
  159. kolega_code/services/browser.py +444 -0
  160. kolega_code/services/file_system.py +749 -0
  161. kolega_code/services/html.py +221 -0
  162. kolega_code/services/terminal.py +903 -0
  163. kolega_code/tools/__init__.py +22 -0
  164. kolega_code/tools/core.py +33 -0
  165. kolega_code/tools/definitions.py +81 -0
  166. kolega_code/tools/registry.py +73 -0
  167. kolega_code-0.1.0.dist-info/METADATA +157 -0
  168. kolega_code-0.1.0.dist-info/RECORD +171 -0
  169. kolega_code-0.1.0.dist-info/WHEEL +4 -0
  170. kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
  171. kolega_code-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,444 @@
1
+ import base64
2
+ import datetime
3
+ import json
4
+ import os
5
+ import urllib.parse
6
+ import uuid
7
+ from typing import List, Optional
8
+ import asyncio
9
+
10
+ from playwright.async_api import async_playwright
11
+
12
+ from .html import extract_interactive_elements_from_html
13
+ from .base import BrowserManager
14
+
15
+
16
+ class PlaywrightBrowserManager(BrowserManager):
17
+
18
+ def __init__(self, use_browserstack: bool = False, browser_backend: str = "local"):
19
+ self.browsers = {}
20
+ self.viewport = {"width": 1280, "height": 720}
21
+ self.user_agent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36"
22
+ self.headless = False
23
+ self.interaction_timeout = 5000
24
+ self.max_console_logs_per_browser = 200 # Maximum logs to keep per browser (circular buffer)
25
+
26
+ # Browser backend configuration
27
+ self.browser_backend = browser_backend
28
+ if use_browserstack: # Legacy support
29
+ self.browser_backend = "browserstack"
30
+
31
+ # BrowserStack configuration
32
+ if self.browser_backend == "browserstack":
33
+ self.browserstack_username = os.environ.get("BROWSERSTACK_USERNAME")
34
+ self.browserstack_access_key = os.environ.get("BROWSERSTACK_ACCESS_KEY")
35
+
36
+ if not self.browserstack_username or not self.browserstack_access_key:
37
+ raise ValueError(
38
+ "BrowserStack credentials not found. Please set BROWSERSTACK_USERNAME and "
39
+ "BROWSERSTACK_ACCESS_KEY environment variables."
40
+ )
41
+
42
+ # Browserless configuration
43
+ elif self.browser_backend == "browserless":
44
+ self.browserless_api_key = os.environ.get("BROWSERLESS_API_KEY")
45
+ if not self.browserless_api_key:
46
+ raise ValueError("Browserless API key not found. Please set BROWSERLESS_API_KEY environment variable.")
47
+
48
+ # Keepalive configuration
49
+ self.keepalive_interval = 30 # Send keepalive every 30 seconds
50
+ self.keepalive_tasks = {} # Store keepalive tasks per browser
51
+
52
+ async def _keepalive_loop(self, browser_id: str):
53
+ """Background task to keep the browser connection alive."""
54
+ while browser_id in self.browsers:
55
+ try:
56
+ browser_info = self.browsers.get(browser_id)
57
+ if not browser_info:
58
+ break
59
+
60
+ page = browser_info.get("page")
61
+ if page:
62
+ # Send a simple keepalive command
63
+ await page.evaluate("() => { /* keepalive */ }")
64
+
65
+ await asyncio.sleep(self.keepalive_interval)
66
+ except Exception as e:
67
+ print(f"[Keepalive] Error for browser {browser_id}: {e}")
68
+ # Sleep shorter on error but continue trying
69
+ await asyncio.sleep(5)
70
+
71
+ def _get_browserstack_cdp_url(self) -> str:
72
+ """Generate the BrowserStack CDP URL with capabilities."""
73
+ capability = {
74
+ "browser": "chrome",
75
+ "browser_version": "latest",
76
+ "os": "Windows",
77
+ "os_version": "11",
78
+ "name": "Kolega Browser Agent",
79
+ "build": "kolega-platform",
80
+ "browserstack.username": self.browserstack_username,
81
+ "browserstack.accessKey": self.browserstack_access_key,
82
+ "browserstack.console": "verbose",
83
+ "browserstack.networkLogs": "true",
84
+ }
85
+
86
+ # According to BrowserStack docs, we need to encode the JSON capabilities
87
+ caps_json = json.dumps(capability)
88
+ caps_string = urllib.parse.quote(caps_json)
89
+ cdp_url = f"wss://cdp.browserstack.com/playwright?caps={caps_string}"
90
+
91
+ return cdp_url
92
+
93
+ def _get_browserless_cdp_url(self) -> str:
94
+ """Generate the Browserless CDP URL."""
95
+ # Browserless cloud CDP URL format for connectOverCDP
96
+ # Format: wss://production-sfo.browserless.io?token=YOUR_TOKEN
97
+ # Alternative regions available: production-lon, production-ams, etc.
98
+
99
+ # Use timeout to control maximum session duration
100
+ # Maximum allowed timeout is 60,000 (units unclear but this value works)
101
+ # This provides either 60 seconds or 60,000ms depending on interpretation
102
+ timeout = 1800000 # Maximum allowed timeout value
103
+
104
+ return f"wss://production-sfo.browserless.io?token={self.browserless_api_key}&timeout={timeout}"
105
+
106
+ async def launch_browser(self, url: str) -> str:
107
+ try:
108
+ playwright = await async_playwright().start()
109
+
110
+ # Connect based on backend
111
+ if self.browser_backend == "browserstack":
112
+ cdp_url = self._get_browserstack_cdp_url()
113
+ browser = await playwright.chromium.connect(ws_endpoint=cdp_url)
114
+ elif self.browser_backend == "browserless":
115
+ cdp_url = self._get_browserless_cdp_url()
116
+ # Use connectOverCDP for browserless as recommended in their docs
117
+ browser = await playwright.chromium.connect_over_cdp(cdp_url)
118
+ else: # local
119
+ browser_factory = playwright.chromium
120
+ browser = await browser_factory.launch(headless=self.headless)
121
+
122
+ context_options = {"viewport": self.viewport, "user_agent": self.user_agent}
123
+ context = await browser.new_context(**context_options)
124
+ page = await context.new_page()
125
+
126
+ console_logs = []
127
+ network_requests = []
128
+
129
+ # Register console log listener BEFORE any navigation
130
+ def console_log_handler(msg):
131
+ log_entry = {
132
+ "type": msg.type,
133
+ "text": msg.text,
134
+ "timestamp": datetime.datetime.now().isoformat(),
135
+ "location": msg.location if hasattr(msg, "location") else None,
136
+ }
137
+ console_logs.append(log_entry)
138
+
139
+ # Implement circular buffer to prevent unlimited growth
140
+ if len(console_logs) > self.max_console_logs_per_browser:
141
+ console_logs.pop(0) # Remove oldest log
142
+
143
+ page.on("console", console_log_handler)
144
+
145
+ # Also capture page errors and unhandled exceptions
146
+ def page_error_handler(error):
147
+ log_entry = {
148
+ "type": "error",
149
+ "text": f"Page Error: {str(error)}",
150
+ "timestamp": datetime.datetime.now().isoformat(),
151
+ "location": None,
152
+ }
153
+ console_logs.append(log_entry)
154
+
155
+ # Implement circular buffer to prevent unlimited growth
156
+ if len(console_logs) > self.max_console_logs_per_browser:
157
+ console_logs.pop(0) # Remove oldest log
158
+
159
+ page.on("pageerror", page_error_handler)
160
+
161
+ # Wait for the listener to be fully registered
162
+ await page.evaluate("() => console.log('Console listener ready')")
163
+
164
+ # Now navigate to the URL with better wait strategy
165
+ await page.goto(url, wait_until="domcontentloaded")
166
+
167
+ browser_id = str(uuid.uuid4())
168
+
169
+ browser_info = {
170
+ "type": "chromium",
171
+ "url": url,
172
+ "playwright": playwright,
173
+ "browser": browser,
174
+ "context": context,
175
+ "page": page,
176
+ "console_logs": console_logs,
177
+ "network_requests": network_requests,
178
+ "launched_at": datetime.datetime.now().isoformat(),
179
+ "browserstack": self.browser_backend == "browserstack",
180
+ "backend": self.browser_backend,
181
+ }
182
+
183
+ self.browsers[browser_id] = browser_info
184
+
185
+ # Start keepalive task for this browser
186
+ keepalive_task = asyncio.create_task(self._keepalive_loop(browser_id))
187
+ self.keepalive_tasks[browser_id] = keepalive_task
188
+
189
+ return browser_id
190
+ except Exception as ex:
191
+ error_msg = f"[Browser Launch Error] {type(ex).__name__}: {str(ex)}"
192
+ print(error_msg)
193
+ # Clean up resources in case of error
194
+ if "playwright" in locals():
195
+ await playwright.stop()
196
+
197
+ # Return error details instead of None
198
+ return {"error": error_msg}
199
+
200
+ async def list_browsers(self) -> dict:
201
+ result = {}
202
+ for browser_id, browser in self.browsers.items():
203
+ result[browser_id] = {
204
+ "url": browser["url"],
205
+ "launched_at": browser["launched_at"],
206
+ "browserstack": browser.get("browserstack", False),
207
+ "backend": browser.get("backend", "unknown"),
208
+ }
209
+
210
+ return result
211
+
212
+ async def get_browser_console_logs(
213
+ self,
214
+ browser_id: str,
215
+ max_logs: int = 50,
216
+ log_types: Optional[List[str]] = None,
217
+ minutes_back: Optional[int] = None,
218
+ max_chars: Optional[int] = 8000,
219
+ ) -> dict:
220
+ """
221
+ Get console logs from a browser with configurable filtering to prevent context window overflow.
222
+
223
+ Args:
224
+ browser_id: The unique identifier of the browser instance
225
+ max_logs: Maximum number of logs to return (most recent)
226
+ log_types: List of log types to include (e.g., ['error', 'warning', 'assert'])
227
+ If None, defaults to important types: ['error', 'warning', 'assert']
228
+ minutes_back: Only return logs from the last N minutes
229
+ max_chars: Maximum total character count for all log messages combined
230
+
231
+ Returns:
232
+ Dictionary containing filtered console logs and metadata
233
+ """
234
+ if browser_id not in self.browsers:
235
+ raise KeyError(f"Browser with ID {browser_id} not found.")
236
+
237
+ browser_info = self.browsers[browser_id]
238
+ console_logs = browser_info["console_logs"].copy() # Work with a copy
239
+ original_count = len(console_logs)
240
+
241
+ # Apply time filter first if specified
242
+ if minutes_back is not None:
243
+ cutoff_time = datetime.datetime.now() - datetime.timedelta(minutes=minutes_back)
244
+ console_logs = [
245
+ log for log in console_logs if datetime.datetime.fromisoformat(log["timestamp"]) > cutoff_time
246
+ ]
247
+
248
+ # Apply type filter - default to important types if not specified
249
+ if log_types is None:
250
+ log_types = ["error", "warning", "assert"]
251
+
252
+ if log_types: # Only filter if log_types is not empty
253
+ console_logs = [log for log in console_logs if log["type"] in log_types]
254
+
255
+ # Apply count limit (most recent)
256
+ if len(console_logs) > max_logs:
257
+ console_logs = console_logs[-max_logs:]
258
+
259
+ # Apply character limit if specified
260
+ if max_chars is not None:
261
+ limited_logs = []
262
+ char_count = 0
263
+ for log in reversed(console_logs):
264
+ log_text = f"{log['type']}: {log['text']}"
265
+ if char_count + len(log_text) > max_chars and limited_logs:
266
+ break
267
+ limited_logs.insert(0, log)
268
+ char_count += len(log_text)
269
+ console_logs = limited_logs
270
+
271
+ return {
272
+ "console_logs": console_logs,
273
+ "total_logs_count": original_count,
274
+ "returned_count": len(console_logs),
275
+ "filters_applied": {
276
+ "max_logs": max_logs,
277
+ "log_types": log_types,
278
+ "minutes_back": minutes_back,
279
+ "max_chars": max_chars,
280
+ },
281
+ }
282
+
283
+ async def get_browser_interactive_elements(self, browser_id: str) -> list:
284
+ if browser_id not in self.browsers:
285
+ raise KeyError(f"Browser with ID {browser_id} not found.")
286
+
287
+ browser_info = self.browsers[browser_id]
288
+ page = browser_info["page"]
289
+ current_url = page.url
290
+ title = await page.title()
291
+ html = await page.content()
292
+
293
+ interactive_elements = extract_interactive_elements_from_html(html)
294
+
295
+ return {"current_url": current_url, "title": title, "interactive_elements": interactive_elements}
296
+
297
+ async def get_browser_content(self, browser_id: str, **console_log_filters) -> dict:
298
+ """
299
+ Get the current content of a browser page including HTML and filtered console logs.
300
+
301
+ Args:
302
+ browser_id: The unique identifier of the browser instance
303
+ **console_log_filters: Optional filters for console logs (max_logs, log_types, minutes_back, max_chars)
304
+
305
+ Returns:
306
+ Dictionary containing current URL, title, HTML content, and filtered console logs
307
+ """
308
+ if browser_id not in self.browsers:
309
+ raise KeyError(f"Browser with ID {browser_id} not found.")
310
+
311
+ browser_info = self.browsers[browser_id]
312
+ page = browser_info["page"]
313
+ current_url = page.url
314
+ title = await page.title()
315
+ html = await page.content()
316
+
317
+ # Get filtered console logs using the new method
318
+ console_log_result = await self.get_browser_console_logs(browser_id, **console_log_filters)
319
+ console_logs = console_log_result["console_logs"]
320
+
321
+ return {
322
+ "current_url": current_url,
323
+ "title": title,
324
+ "html": html,
325
+ "console_logs": console_logs,
326
+ "console_log_metadata": {
327
+ "total_logs_count": console_log_result["total_logs_count"],
328
+ "returned_count": console_log_result["returned_count"],
329
+ "filters_applied": console_log_result["filters_applied"],
330
+ },
331
+ }
332
+
333
+ async def take_browser_screenshot(self, browser_id: str) -> dict:
334
+ if browser_id not in self.browsers:
335
+ raise KeyError(f"Browser with ID {browser_id} not found.")
336
+
337
+ browser_info = self.browsers[browser_id]
338
+ page = browser_info["page"]
339
+ current_url = page.url
340
+ title = await page.title()
341
+
342
+ screenshot = base64.b64encode(await page.screenshot()).decode("utf-8")
343
+
344
+ return {"current_url": current_url, "title": title, "screenshot": screenshot}
345
+
346
+ async def interact_with_browser(self, browser_id: str, action: str, selector: str, text: str, scroll_px) -> dict:
347
+ if browser_id not in self.browsers:
348
+ raise KeyError(f"Browser with ID {browser_id} not found.")
349
+
350
+ browser_info = self.browsers[browser_id]
351
+ page = browser_info["page"]
352
+
353
+ if action == "click":
354
+ await page.click(selector, timeout=self.interaction_timeout)
355
+ elif action == "type":
356
+ await page.fill(selector, text)
357
+ elif action == "scroll":
358
+ await page.evaluate(f"window.scrollBy(0, {scroll_px})")
359
+ elif action == "navigate":
360
+ await page.goto(text, wait_until="domcontentloaded")
361
+ else:
362
+ raise ValueError(f"Unknown action: {action}")
363
+
364
+ await page.wait_for_load_state("networkidle")
365
+
366
+ return {"status": "success", "current_url": page.url, "action": action, "selector": selector, "text": text}
367
+
368
+ async def set_select_value(self, browser_id: str, selector: str, value: str) -> dict:
369
+ if browser_id not in self.browsers:
370
+ raise KeyError(f"Browser with ID {browser_id} not found.")
371
+
372
+ browser_info = self.browsers[browser_id]
373
+ page = browser_info["page"]
374
+
375
+ try:
376
+ # First, check if the element exists and is a select element
377
+ element = await page.query_selector(selector)
378
+ if not element:
379
+ raise ValueError(f"Element with selector '{selector}' not found.")
380
+
381
+ # Check if it's actually a select element
382
+ tag_name = await element.evaluate("el => el.tagName.toLowerCase()")
383
+ if tag_name != "select":
384
+ raise ValueError(f"Element with selector '{selector}' is not a select box (found: {tag_name}).")
385
+
386
+ # Get all option values to validate the value exists
387
+ option_values = await element.evaluate(
388
+ """
389
+ el => Array.from(el.options).map(option => option.value)
390
+ """
391
+ )
392
+
393
+ if value not in option_values:
394
+ raise ValueError(f"Value '{value}' not found in select options. Available values: {option_values}")
395
+
396
+ # Set the value using Playwright's select_option method
397
+ await page.select_option(selector, value, timeout=self.interaction_timeout)
398
+
399
+ # Get the currently selected value to confirm
400
+ selected_value = await element.evaluate("el => el.value")
401
+
402
+ return {
403
+ "status": "success",
404
+ "current_url": page.url,
405
+ "selector": selector,
406
+ "selected_value": selected_value,
407
+ "requested_value": value,
408
+ }
409
+
410
+ except Exception as e:
411
+ return {"status": "error", "current_url": page.url, "selector": selector, "error": str(e)}
412
+
413
+ async def close_browser(self, browser_id: str) -> None:
414
+ if browser_id not in self.browsers:
415
+ raise KeyError(f"Browser with ID {browser_id} not found.")
416
+
417
+ # Cancel the keepalive task for this browser
418
+ if browser_id in self.keepalive_tasks:
419
+ self.keepalive_tasks[browser_id].cancel()
420
+ del self.keepalive_tasks[browser_id]
421
+
422
+ await self.browsers[browser_id]["browser"].close()
423
+ await self.browsers[browser_id]["playwright"].stop()
424
+
425
+ del self.browsers[browser_id]
426
+
427
+ async def cleanup_all_browsers(self) -> None:
428
+ """
429
+ Close all open browser instances and clean up resources.
430
+ This should be called when the browser manager is being destroyed.
431
+ """
432
+ browser_ids = list(self.browsers.keys()) # Create a copy of keys to avoid modification during iteration
433
+
434
+ for browser_id in browser_ids:
435
+ try:
436
+ await self.close_browser(browser_id)
437
+ except Exception as e:
438
+ # Log error but continue closing other browsers
439
+ print(f"Error closing browser {browser_id}: {e}")
440
+
441
+ # Cancel any remaining keepalive tasks (cleanup safety)
442
+ for task_id, task in list(self.keepalive_tasks.items()):
443
+ task.cancel()
444
+ self.keepalive_tasks.clear()