narada 0.1.31__tar.gz → 0.1.33a1__tar.gz

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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: narada
3
- Version: 0.1.31
3
+ Version: 0.1.33a1
4
4
  Summary: Python client SDK for Narada
5
5
  Project-URL: Homepage, https://github.com/NaradaAI/narada-python-sdk/narada
6
6
  Project-URL: Repository, https://github.com/NaradaAI/narada-python-sdk
@@ -9,7 +9,7 @@ Author-email: Narada <support@narada.ai>
9
9
  License-Expression: Apache-2.0
10
10
  Requires-Python: >=3.12
11
11
  Requires-Dist: aiohttp>=3.12.13
12
- Requires-Dist: narada-core==0.0.9
12
+ Requires-Dist: narada-core==0.0.10
13
13
  Requires-Dist: playwright>=1.53.0
14
14
  Requires-Dist: rich>=14.0.0
15
15
  Requires-Dist: semver>=3.0.4
@@ -1,13 +1,13 @@
1
1
  [project]
2
2
  name = "narada"
3
- version = "0.1.31"
3
+ version = "0.1.33a1"
4
4
  description = "Python client SDK for Narada"
5
5
  license = "Apache-2.0"
6
6
  readme = "README.md"
7
7
  authors = [{ name = "Narada", email = "support@narada.ai" }]
8
8
  requires-python = ">=3.12"
9
9
  dependencies = [
10
- "narada-core==0.0.9",
10
+ "narada-core==0.0.10",
11
11
  "aiohttp>=3.12.13",
12
12
  "playwright>=1.53.0",
13
13
  "rich>=14.0.0",
@@ -1,3 +1,8 @@
1
+ from narada.client import Narada
2
+ from narada.config import BrowserConfig, ProxyConfig
3
+ from narada.utils import download_file, render_html
4
+ from narada.version import __version__
5
+ from narada.window import CloudBrowserWindow, LocalBrowserWindow, RemoteBrowserWindow
1
6
  from narada_core.errors import (
2
7
  NaradaError,
3
8
  NaradaExtensionMissingError,
@@ -8,16 +13,11 @@ from narada_core.errors import (
8
13
  )
9
14
  from narada_core.models import Agent, File, Response, ResponseContent
10
15
 
11
- from narada.client import Narada
12
- from narada.config import BrowserConfig, ProxyConfig
13
- from narada.utils import download_file, render_html
14
- from narada.version import __version__
15
- from narada.window import LocalBrowserWindow, RemoteBrowserWindow
16
-
17
16
  __all__ = [
18
17
  "__version__",
19
18
  "Agent",
20
19
  "BrowserConfig",
20
+ "CloudBrowserWindow",
21
21
  "download_file",
22
22
  "File",
23
23
  "LocalBrowserWindow",
@@ -11,6 +11,14 @@ from uuid import uuid4
11
11
 
12
12
  import aiohttp
13
13
  import semver
14
+ from narada.config import BrowserConfig, ProxyConfig
15
+ from narada.utils import assert_never
16
+ from narada.version import __version__
17
+ from narada.window import (
18
+ LocalBrowserWindow,
19
+ CloudBrowserWindow,
20
+ create_side_panel_url,
21
+ )
14
22
  from narada_core.errors import (
15
23
  NaradaExtensionMissingError,
16
24
  NaradaExtensionUnauthenticatedError,
@@ -26,19 +34,14 @@ from playwright.async_api import (
26
34
  ElementHandle,
27
35
  Page,
28
36
  Playwright,
29
- async_playwright,
30
37
  )
38
+ from playwright.async_api import TimeoutError as PlaywrightTimeoutError
31
39
  from playwright.async_api import (
32
- TimeoutError as PlaywrightTimeoutError,
40
+ async_playwright,
33
41
  )
34
42
  from playwright.async_api._context_manager import PlaywrightContextManager
35
43
  from rich.console import Console
36
44
 
37
- from narada.config import BrowserConfig, ProxyConfig
38
- from narada.utils import assert_never
39
- from narada.version import __version__
40
- from narada.window import LocalBrowserWindow, create_side_panel_url
41
-
42
45
 
43
46
  @dataclass
44
47
  class _LaunchBrowserResult:
@@ -58,10 +61,12 @@ class Narada:
58
61
  _console: Console
59
62
  _playwright_context_manager: PlaywrightContextManager | None = None
60
63
  _playwright: Playwright | None = None
64
+ _cloud_windows: set[CloudBrowserWindow]
61
65
 
62
66
  def __init__(self, *, api_key: str | None = None) -> None:
63
67
  self._api_key = api_key or os.environ["NARADA_API_KEY"]
64
68
  self._console = Console()
69
+ self._cloud_windows = set()
65
70
 
66
71
  async def __aenter__(self) -> Narada:
67
72
  await self._validate_sdk_config()
@@ -71,6 +76,11 @@ class Narada:
71
76
  return self
72
77
 
73
78
  async def __aexit__(self, *args: Any) -> None:
79
+ async with asyncio.TaskGroup() as tg:
80
+ for cloud_window in self._cloud_windows:
81
+ tg.create_task(cloud_window.cleanup())
82
+ self._cloud_windows.clear()
83
+
74
84
  if self._playwright_context_manager is None:
75
85
  return
76
86
 
@@ -134,6 +144,121 @@ class Narada:
134
144
  context=side_panel_page.context,
135
145
  )
136
146
 
147
+ async def open_and_initialize_cloud_browser_window(
148
+ self,
149
+ config: BrowserConfig | None = None,
150
+ session_name: str | None = None,
151
+ session_timeout: int | None = None,
152
+ ) -> CloudBrowserWindow:
153
+ """Creates a cloud browser by calling the backend.
154
+
155
+ The backend creates a cloud browser session and returns
156
+ a CDP WebSocket URL. This method connects to it, initializes the extension,
157
+ and returns a CloudBrowserWindow instance.
158
+ """
159
+ assert self._playwright is not None
160
+ playwright = self._playwright
161
+
162
+ config = config or BrowserConfig()
163
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
164
+ request_body = {
165
+ "session_name": session_name,
166
+ "session_timeout": session_timeout,
167
+ }
168
+ endpoint_url = f"{base_url}/cloud-browser/create-cloud-browser-session"
169
+
170
+ async with aiohttp.ClientSession() as session:
171
+ async with session.post(
172
+ endpoint_url,
173
+ headers={"x-api-key": self._api_key},
174
+ json=request_body,
175
+ timeout=aiohttp.ClientTimeout(
176
+ total=180
177
+ ), # 3 minutes for session startup
178
+ ) as resp:
179
+ if not resp.ok:
180
+ error_text = await resp.text()
181
+ raise RuntimeError(
182
+ f"Failed to create cloud browser session: {resp.status} {error_text}\n"
183
+ f"Endpoint URL: {endpoint_url}"
184
+ )
185
+ response_data = await resp.json()
186
+
187
+ cdp_websocket_url = response_data["cdp_websocket_url"]
188
+ session_id = response_data["session_id"]
189
+ login_url = response_data["login_url"]
190
+ cdp_auth_headers = response_data.get("cdp_auth_headers")
191
+
192
+ # Connect to browser via CDP with authentication headers
193
+ try:
194
+ browser = await playwright.chromium.connect_over_cdp(
195
+ cdp_websocket_url, headers=cdp_auth_headers
196
+ )
197
+ except Exception:
198
+ # Clean up the session if CDP connection fails
199
+ try:
200
+ async with aiohttp.ClientSession() as cleanup_session:
201
+ async with cleanup_session.post(
202
+ f"{base_url}/cloud-browser/stop-cloud-browser-session",
203
+ headers={"x-api-key": self._api_key},
204
+ json={"session_id": session_id},
205
+ timeout=aiohttp.ClientTimeout(total=10),
206
+ ) as resp:
207
+ if resp.ok:
208
+ logging.info(
209
+ f"Cleaned up session {session_id} after CDP connection failure"
210
+ )
211
+ else:
212
+ logging.warning(
213
+ f"Failed to cleanup session {session_id}: {resp.status}"
214
+ )
215
+ except Exception as cleanup_error:
216
+ logging.warning(
217
+ f"Error cleaning up session {session_id}: {cleanup_error}"
218
+ )
219
+ # Re-raise the original connection error
220
+ raise
221
+ context = (
222
+ browser.contexts[0] if browser.contexts else await browser.new_context()
223
+ )
224
+
225
+ # Navigate to login URL (provided by backend with custom token)
226
+ initialization_page = await context.new_page()
227
+ await initialization_page.goto(
228
+ login_url, wait_until="domcontentloaded", timeout=60_000
229
+ )
230
+
231
+ # Wait for sign-in to process to complete.
232
+ await asyncio.sleep(15) # TODO: improve it in the future
233
+ await initialization_page.reload(wait_until="domcontentloaded", timeout=60_000)
234
+
235
+ # Wait for browser window ID
236
+ browser_window_id = await self._wait_for_browser_window_id(
237
+ initialization_page, config
238
+ )
239
+
240
+ # TODO: consider this
241
+ # Get side panel page
242
+ # side_panel_url = create_side_panel_url(config, browser_window_id)
243
+ # side_panel_page = next(
244
+ # (p for p in context.pages if p.url == side_panel_url), None
245
+ # )
246
+ # await self._fix_download_behavior(side_panel_page)
247
+
248
+ cloud_window = CloudBrowserWindow(
249
+ browser_window_id=browser_window_id,
250
+ session_id=session_id,
251
+ api_key=self._api_key,
252
+ )
253
+
254
+ # Track the window for cleanup in __aexit__
255
+ self._cloud_windows.add(cloud_window)
256
+
257
+ if config.interactive:
258
+ self._print_success_message(browser_window_id)
259
+
260
+ return cloud_window
261
+
137
262
  async def initialize_in_existing_browser_window(
138
263
  self, config: BrowserConfig | None = None
139
264
  ) -> LocalBrowserWindow:
@@ -1,37 +1,39 @@
1
1
  import asyncio
2
+ import logging
2
3
  import os
3
4
  import time
4
5
  from abc import ABC
5
6
  from http import HTTPStatus
6
7
  from pathlib import Path
7
- from typing import IO, Any, Optional, TypeVar, overload
8
+ from typing import IO, Any, TypeVar, overload
8
9
 
9
10
  import aiohttp
11
+ from narada.config import BrowserConfig
10
12
  from narada_core.actions.models import (
11
- ActionTraceItem,
13
+ AgenticMouseAction,
14
+ AgenticMouseActionRequest,
12
15
  AgenticSelectorAction,
13
16
  AgenticSelectorRequest,
14
17
  AgenticSelectorResponse,
15
- AgenticMouseActionRequest,
16
- AgenticMouseAction,
17
18
  AgenticSelectors,
18
19
  AgentResponse,
19
20
  AgentUsage,
20
21
  CloseWindowRequest,
21
22
  ExtensionActionRequest,
22
23
  ExtensionActionResponse,
24
+ GetFullHtmlRequest,
25
+ GetFullHtmlResponse,
26
+ GetScreenshotRequest,
27
+ GetScreenshotResponse,
28
+ GetSimplifiedHtmlRequest,
29
+ GetSimplifiedHtmlResponse,
23
30
  GoToUrlRequest,
24
31
  PrintMessageRequest,
25
32
  ReadGoogleSheetRequest,
26
33
  ReadGoogleSheetResponse,
27
- WriteGoogleSheetRequest,
28
34
  RecordedClick,
29
- GetFullHtmlRequest,
30
- GetFullHtmlResponse,
31
- GetSimplifiedHtmlRequest,
32
- GetSimplifiedHtmlResponse,
33
- GetScreenshotRequest,
34
- GetScreenshotResponse,
35
+ WriteGoogleSheetRequest,
36
+ parse_action_trace,
35
37
  )
36
38
  from narada_core.errors import (
37
39
  NaradaAgentTimeoutError_INTERNAL_DO_NOT_USE,
@@ -41,14 +43,17 @@ from narada_core.errors import (
41
43
  from narada_core.models import (
42
44
  Agent,
43
45
  File,
46
+ McpServer,
44
47
  RemoteDispatchChatHistoryItem,
45
48
  Response,
46
49
  UserResourceCredentials,
47
50
  )
48
- from playwright.async_api import BrowserContext
51
+ from playwright.async_api import (
52
+ BrowserContext,
53
+ )
49
54
  from pydantic import BaseModel
50
55
 
51
- from narada.config import BrowserConfig
56
+ logger = logging.getLogger(__name__)
52
57
 
53
58
  _StructuredOutput = TypeVar("_StructuredOutput", bound=BaseModel)
54
59
 
@@ -129,6 +134,7 @@ class BaseBrowserWindow(ABC):
129
134
  attachment: File | None = None,
130
135
  time_zone: str = "America/Los_Angeles",
131
136
  user_resource_credentials: UserResourceCredentials | None = None,
137
+ mcp_servers: list[McpServer] | None = None,
132
138
  variables: dict[str, str] | None = None,
133
139
  callback_url: str | None = None,
134
140
  callback_secret: str | None = None,
@@ -151,6 +157,7 @@ class BaseBrowserWindow(ABC):
151
157
  attachment: File | None = None,
152
158
  time_zone: str = "America/Los_Angeles",
153
159
  user_resource_credentials: UserResourceCredentials | None = None,
160
+ mcp_servers: list[McpServer] | None = None,
154
161
  variables: dict[str, str] | None = None,
155
162
  callback_url: str | None = None,
156
163
  callback_secret: str | None = None,
@@ -172,6 +179,7 @@ class BaseBrowserWindow(ABC):
172
179
  attachment: File | None = None,
173
180
  time_zone: str = "America/Los_Angeles",
174
181
  user_resource_credentials: UserResourceCredentials | None = None,
182
+ mcp_servers: list[McpServer] | None = None,
175
183
  variables: dict[str, str] | None = None,
176
184
  callback_url: str | None = None,
177
185
  callback_secret: str | None = None,
@@ -213,6 +221,10 @@ class BaseBrowserWindow(ABC):
213
221
  body["attachment"] = attachment
214
222
  if user_resource_credentials is not None:
215
223
  body["userResourceCredentials"] = user_resource_credentials
224
+ if mcp_servers is not None:
225
+ body["mcpServers"] = [
226
+ server.model_dump(mode="json") for server in mcp_servers
227
+ ]
216
228
  if variables is not None:
217
229
  body["variables"] = variables
218
230
  if callback_url is not None:
@@ -278,6 +290,7 @@ class BaseBrowserWindow(ABC):
278
290
  output_schema: None = None,
279
291
  attachment: File | None = None,
280
292
  time_zone: str = "America/Los_Angeles",
293
+ mcp_servers: list[McpServer] | None = None,
281
294
  variables: dict[str, str] | None = None,
282
295
  timeout: int = 1000,
283
296
  ) -> AgentResponse[None]: ...
@@ -293,6 +306,7 @@ class BaseBrowserWindow(ABC):
293
306
  output_schema: type[_StructuredOutput],
294
307
  attachment: File | None = None,
295
308
  time_zone: str = "America/Los_Angeles",
309
+ mcp_servers: list[McpServer] | None = None,
296
310
  variables: dict[str, str] | None = None,
297
311
  timeout: int = 1000,
298
312
  ) -> AgentResponse[_StructuredOutput]: ...
@@ -307,6 +321,7 @@ class BaseBrowserWindow(ABC):
307
321
  output_schema: type[BaseModel] | None = None,
308
322
  attachment: File | None = None,
309
323
  time_zone: str = "America/Los_Angeles",
324
+ mcp_servers: list[McpServer] | None = None,
310
325
  variables: dict[str, str] | None = None,
311
326
  timeout: int = 1000,
312
327
  ) -> AgentResponse:
@@ -319,6 +334,7 @@ class BaseBrowserWindow(ABC):
319
334
  output_schema=output_schema,
320
335
  attachment=attachment,
321
336
  time_zone=time_zone,
337
+ mcp_servers=mcp_servers,
322
338
  variables=variables,
323
339
  timeout=timeout,
324
340
  )
@@ -327,7 +343,7 @@ class BaseBrowserWindow(ABC):
327
343
 
328
344
  action_trace_raw = response_content.get("actionTrace")
329
345
  action_trace = (
330
- [ActionTraceItem.model_validate(item) for item in action_trace_raw]
346
+ parse_action_trace(action_trace_raw)
331
347
  if action_trace_raw is not None
332
348
  else None
333
349
  )
@@ -362,14 +378,13 @@ class BaseBrowserWindow(ABC):
362
378
  AgenticSelectorRequest(
363
379
  action=action,
364
380
  selectors=selectors,
365
- response_model=response_model,
366
381
  fallback_operator_query=fallback_operator_query,
367
382
  ),
368
383
  timeout=timeout,
369
384
  )
370
385
 
371
386
  if result is None:
372
- return {"value": None}
387
+ return AgenticSelectorResponse(value=None)
373
388
 
374
389
  return result
375
390
 
@@ -542,9 +557,10 @@ class LocalBrowserWindow(BaseBrowserWindow):
542
557
  config: BrowserConfig,
543
558
  context: BrowserContext,
544
559
  ) -> None:
560
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
545
561
  super().__init__(
546
562
  api_key=api_key,
547
- base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
563
+ base_url=base_url,
548
564
  browser_window_id=browser_window_id,
549
565
  )
550
566
  self._browser_process_id = browser_process_id
@@ -576,9 +592,10 @@ class LocalBrowserWindow(BaseBrowserWindow):
576
592
 
577
593
  class RemoteBrowserWindow(BaseBrowserWindow):
578
594
  def __init__(self, *, browser_window_id: str, api_key: str | None = None) -> None:
595
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
579
596
  super().__init__(
580
597
  api_key=api_key or os.environ["NARADA_API_KEY"],
581
- base_url=os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2"),
598
+ base_url=base_url,
582
599
  browser_window_id=browser_window_id,
583
600
  )
584
601
 
@@ -586,5 +603,51 @@ class RemoteBrowserWindow(BaseBrowserWindow):
586
603
  return f"RemoteBrowserWindow(browser_window_id={self.browser_window_id})"
587
604
 
588
605
 
606
+ class CloudBrowserWindow(BaseBrowserWindow):
607
+ """A browser window that connects to a backend-cloud browser session via CDP.
608
+
609
+ This class connects to a cloud browser session created by the backend API and provides
610
+ the same interface as other browser window classes for agent operations.
611
+ """
612
+
613
+ def __init__(
614
+ self,
615
+ *,
616
+ browser_window_id: str,
617
+ session_id: str,
618
+ api_key: str | None = None,
619
+ ) -> None:
620
+ base_url = os.getenv("NARADA_API_BASE_URL", "https://api.narada.ai/fast/v2")
621
+ super().__init__(
622
+ api_key=api_key or os.environ["NARADA_API_KEY"],
623
+ base_url=base_url,
624
+ browser_window_id=browser_window_id,
625
+ )
626
+ self._session_id = session_id
627
+
628
+ async def cleanup(self) -> None:
629
+ """Stop the cloud browser session."""
630
+ try:
631
+ async with aiohttp.ClientSession() as session:
632
+ async with session.post(
633
+ f"{self._base_url}/cloud-browser/stop-cloud-browser-session",
634
+ headers={"x-api-key": self._api_key},
635
+ json={
636
+ "session_id": self._session_id,
637
+ },
638
+ timeout=aiohttp.ClientTimeout(total=10),
639
+ ) as resp:
640
+ if resp.ok:
641
+ response_data = await resp.json()
642
+ if not response_data.get("success"):
643
+ logger.warning(
644
+ f"Failed to stop session: {response_data.get('message')}"
645
+ )
646
+ else:
647
+ logger.warning(f"Failed to stop session: {resp.status}")
648
+ except Exception as e:
649
+ logger.warning(f"Error calling stop session endpoint: {e}")
650
+
651
+
589
652
  def create_side_panel_url(config: BrowserConfig, browser_window_id: str) -> str:
590
653
  return f"chrome-extension://{config.extension_id}/sidepanel.html?browserWindowId={browser_window_id}"
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes