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.
- kolega_code/__init__.py +151 -0
- kolega_code/agent/__init__.py +42 -0
- kolega_code/agent/baseagent.py +998 -0
- kolega_code/agent/browseragent.py +123 -0
- kolega_code/agent/coder.py +157 -0
- kolega_code/agent/common.py +41 -0
- kolega_code/agent/compression.py +81 -0
- kolega_code/agent/context.py +112 -0
- kolega_code/agent/conversation.py +408 -0
- kolega_code/agent/generalagent.py +146 -0
- kolega_code/agent/investigationagent.py +123 -0
- kolega_code/agent/planningagent.py +187 -0
- kolega_code/agent/prompt_provider.py +196 -0
- kolega_code/agent/prompt_templates/agents/browser.j2 +102 -0
- kolega_code/agent/prompt_templates/agents/coder_cli_mode.j2 +127 -0
- kolega_code/agent/prompt_templates/agents/general.j2 +68 -0
- kolega_code/agent/prompt_templates/agents/investigation.j2 +72 -0
- kolega_code/agent/prompt_templates/common/frontend_guidance.md +36 -0
- kolega_code/agent/prompt_templates/common/kolega_md_instructions.md +14 -0
- kolega_code/agent/prompt_templates/environment_variables/workspace_env_vars.md +11 -0
- kolega_code/agent/prompt_templates/template_guidance/expo-template.md +379 -0
- kolega_code/agent/prompt_templates/template_guidance/html-website-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/mern-stack-template.md +3 -0
- kolega_code/agent/prompt_templates/template_guidance/react-vite-shadcdn-template.md +182 -0
- kolega_code/agent/prompts.py +192 -0
- kolega_code/agent/tests/__init__.py +0 -0
- kolega_code/agent/tests/llm/__init__.py +0 -0
- kolega_code/agent/tests/llm/test_anthropic_token_counting.py +633 -0
- kolega_code/agent/tests/llm/test_billing_openai_cache.py +74 -0
- kolega_code/agent/tests/llm/test_client.py +773 -0
- kolega_code/agent/tests/llm/test_dashscope_mapping.py +32 -0
- kolega_code/agent/tests/llm/test_error_boundary.py +322 -0
- kolega_code/agent/tests/llm/test_exceptions.py +249 -0
- kolega_code/agent/tests/llm/test_instrumented_client.py +536 -0
- kolega_code/agent/tests/llm/test_instrumented_client_integration.py +547 -0
- kolega_code/agent/tests/llm/test_langfuse_normalization.py +39 -0
- kolega_code/agent/tests/llm/test_model_specs.py +17 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens.py +58 -0
- kolega_code/agent/tests/llm/test_openai_cached_tokens_stream.py +74 -0
- kolega_code/agent/tests/llm/test_openai_message_conversion.py +30 -0
- kolega_code/agent/tests/llm/test_openai_token_counting.py +687 -0
- kolega_code/agent/tests/llm/test_tool_execution_ids.py +193 -0
- kolega_code/agent/tests/services/__init__.py +1 -0
- kolega_code/agent/tests/services/test_browser.py +447 -0
- kolega_code/agent/tests/services/test_browser_parity.py +353 -0
- kolega_code/agent/tests/services/test_file_system.py +699 -0
- kolega_code/agent/tests/services/test_sandbox_terminal_input.py +98 -0
- kolega_code/agent/tests/services/test_terminal.py +154 -0
- kolega_code/agent/tests/services/test_terminal_command_tracking.py +385 -0
- kolega_code/agent/tests/services/test_terminal_state_serializer.py +262 -0
- kolega_code/agent/tests/test_agent_tools_inventory.py +267 -0
- kolega_code/agent/tests/test_base_agent.py +1942 -0
- kolega_code/agent/tests/test_coder_attachments.py +330 -0
- kolega_code/agent/tests/test_coder_prompt_extensions.py +61 -0
- kolega_code/agent/tests/test_commands.py +179 -0
- kolega_code/agent/tests/test_duplicate_tool_results.py +556 -0
- kolega_code/agent/tests/test_empty_message_handling.py +48 -0
- kolega_code/agent/tests/test_general_agent.py +242 -0
- kolega_code/agent/tests/test_html.py +320 -0
- kolega_code/agent/tests/test_parallel_tool_calls.py +291 -0
- kolega_code/agent/tests/test_planning_agent.py +227 -0
- kolega_code/agent/tests/test_prompt_provider.py +271 -0
- kolega_code/agent/tests/test_tool_registry.py +102 -0
- kolega_code/agent/tests/test_tools.py +549 -0
- kolega_code/agent/tests/tool_backend/__init__.py +0 -0
- kolega_code/agent/tests/tool_backend/test_agent_tool.py +356 -0
- kolega_code/agent/tests/tool_backend/test_base_tool.py +147 -0
- kolega_code/agent/tests/tool_backend/test_browser_tool.py +335 -0
- kolega_code/agent/tests/tool_backend/test_build_tool.py +93 -0
- kolega_code/agent/tests/tool_backend/test_create_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool.py +196 -0
- kolega_code/agent/tests/tool_backend/test_glob_tool_sandbox_parity.py +230 -0
- kolega_code/agent/tests/tool_backend/test_list_directory_tool.py +292 -0
- kolega_code/agent/tests/tool_backend/test_read_file_tool.py +173 -0
- kolega_code/agent/tests/tool_backend/test_replace_entire_file_tool.py +115 -0
- kolega_code/agent/tests/tool_backend/test_replace_lines_tool.py +141 -0
- kolega_code/agent/tests/tool_backend/test_search_and_replace_tool.py +174 -0
- kolega_code/agent/tests/tool_backend/test_search_codebase_tool.py +228 -0
- kolega_code/agent/tests/tool_backend/test_terminal_tool.py +482 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_integration.py +189 -0
- kolega_code/agent/tests/tool_backend/test_think_hard_streaming.py +445 -0
- kolega_code/agent/tests/tool_backend/test_web_fetch_tool.py +194 -0
- kolega_code/agent/tool_backend/agent_tool.py +414 -0
- kolega_code/agent/tool_backend/apply_edit_tool.py +98 -0
- kolega_code/agent/tool_backend/apply_patch_tool.py +514 -0
- kolega_code/agent/tool_backend/base_tool.py +217 -0
- kolega_code/agent/tool_backend/browser_tool.py +271 -0
- kolega_code/agent/tool_backend/build_tool.py +93 -0
- kolega_code/agent/tool_backend/create_file_tool.py +52 -0
- kolega_code/agent/tool_backend/glob_tool.py +323 -0
- kolega_code/agent/tool_backend/list_directory_tool.py +300 -0
- kolega_code/agent/tool_backend/memory_tool.py +79 -0
- kolega_code/agent/tool_backend/read_file_tool.py +119 -0
- kolega_code/agent/tool_backend/replace_entire_file_tool.py +40 -0
- kolega_code/agent/tool_backend/replace_lines_tool.py +97 -0
- kolega_code/agent/tool_backend/search_and_replace_tool.py +146 -0
- kolega_code/agent/tool_backend/search_codebase_tool.py +377 -0
- kolega_code/agent/tool_backend/streaming_tool.py +47 -0
- kolega_code/agent/tool_backend/terminal_tool.py +643 -0
- kolega_code/agent/tool_backend/think_hard_tool.py +211 -0
- kolega_code/agent/tool_backend/web_fetch_tool.py +205 -0
- kolega_code/agent/tools.py +1704 -0
- kolega_code/agent/utils/commands.py +94 -0
- kolega_code/cli/__init__.py +1 -0
- kolega_code/cli/app.py +2756 -0
- kolega_code/cli/config.py +280 -0
- kolega_code/cli/connection.py +49 -0
- kolega_code/cli/file_index.py +147 -0
- kolega_code/cli/main.py +564 -0
- kolega_code/cli/mentions.py +155 -0
- kolega_code/cli/messages.py +89 -0
- kolega_code/cli/provider_registry.py +96 -0
- kolega_code/cli/session_store.py +207 -0
- kolega_code/cli/settings.py +87 -0
- kolega_code/cli/skills.py +409 -0
- kolega_code/cli/slash_commands.py +108 -0
- kolega_code/cli/tests/__init__.py +1 -0
- kolega_code/cli/tests/test_app.py +4251 -0
- kolega_code/cli/tests/test_cli_config.py +171 -0
- kolega_code/cli/tests/test_connection.py +26 -0
- kolega_code/cli/tests/test_file_index.py +103 -0
- kolega_code/cli/tests/test_main.py +455 -0
- kolega_code/cli/tests/test_mentions.py +108 -0
- kolega_code/cli/tests/test_session_store.py +67 -0
- kolega_code/cli/tests/test_settings.py +62 -0
- kolega_code/cli/tests/test_skills.py +157 -0
- kolega_code/cli/tests/test_slash_commands.py +88 -0
- kolega_code/cli/theme.py +180 -0
- kolega_code/config.py +154 -0
- kolega_code/events.py +202 -0
- kolega_code/llm/client.py +300 -0
- kolega_code/llm/exceptions.py +285 -0
- kolega_code/llm/instrumented_client.py +520 -0
- kolega_code/llm/models.py +1368 -0
- kolega_code/llm/providers/__init__.py +0 -0
- kolega_code/llm/providers/anthropic.py +387 -0
- kolega_code/llm/providers/base.py +71 -0
- kolega_code/llm/providers/google.py +157 -0
- kolega_code/llm/providers/models.py +37 -0
- kolega_code/llm/providers/openai.py +363 -0
- kolega_code/llm/ratelimit.py +40 -0
- kolega_code/llm/specs.py +67 -0
- kolega_code/llm/tool_execution_ids.py +18 -0
- kolega_code/models/__init__.py +9 -0
- kolega_code/models/sandbox_terminal_state.py +47 -0
- kolega_code/runtime.py +50 -0
- kolega_code/sandbox/README.md +200 -0
- kolega_code/sandbox/__init__.py +21 -0
- kolega_code/sandbox/async_filesystem.py +475 -0
- kolega_code/sandbox/base.py +297 -0
- kolega_code/sandbox/browser.py +25 -0
- kolega_code/sandbox/event_loop.py +43 -0
- kolega_code/sandbox/filesystem.py +341 -0
- kolega_code/sandbox/local.py +118 -0
- kolega_code/sandbox/serializer.py +175 -0
- kolega_code/sandbox/terminal.py +868 -0
- kolega_code/sandbox/utils.py +216 -0
- kolega_code/services/base.py +255 -0
- kolega_code/services/browser.py +444 -0
- kolega_code/services/file_system.py +749 -0
- kolega_code/services/html.py +221 -0
- kolega_code/services/terminal.py +903 -0
- kolega_code/tools/__init__.py +22 -0
- kolega_code/tools/core.py +33 -0
- kolega_code/tools/definitions.py +81 -0
- kolega_code/tools/registry.py +73 -0
- kolega_code-0.1.0.dist-info/METADATA +157 -0
- kolega_code-0.1.0.dist-info/RECORD +171 -0
- kolega_code-0.1.0.dist-info/WHEEL +4 -0
- kolega_code-0.1.0.dist-info/entry_points.txt +2 -0
- 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()
|