diff --git a/examples/cloud_browser.py b/examples/cloud_browser.py index 53feee2..f641ea6 100644 --- a/examples/cloud_browser.py +++ b/examples/cloud_browser.py @@ -1,16 +1,19 @@ import asyncio from narada import Narada +from narada.window import RemoteBrowserWindow async def main() -> None: + # Initialize the Narada client. async with Narada() as narada: + # Open a cloud browser window and initialize the Narada UI agent. window = await narada.open_and_initialize_cloud_browser_window( session_name="my-cloud-browser-session", # Optional: label the session session_timeout=3600, # Optional: session timeout in seconds ) - # Run a task in this browser window + # Run a task in this browser window. response = await window.agent( prompt=( 'Search for "LLM Compiler" on Google and open the first arXiv paper on the results ' @@ -20,6 +23,39 @@ async def main() -> None: print("Response:", response.model_dump_json(indent=2)) + # The cloud session is still running after exiting the context manager. + # You can save the session ID for later reconnection or management. + cloud_browser_session_id = window.cloud_browser_session_id + browser_window_id = window.browser_window_id + + # Change these to test the different options below. + stop_session_now = False + + # The cloud session runs independently. If you want to stop it after the task is + # complete, you can explicitly close it. The session will also auto-expire after the + # configured session_timeout. + if stop_session_now: + print( + f"Stopping cloud session {cloud_browser_session_id} through original window" + ) + await window.close() + else: + # Create a `RemoteBrowserWindow` instance with the session ID to manage the session later. + print( + f"Stopping cloud session {cloud_browser_session_id} through RemoteBrowserWindow" + ) + remote_window = RemoteBrowserWindow( + cloud_browser_session_id=cloud_browser_session_id, + browser_window_id=browser_window_id, + ) + await remote_window.close() # This will stop the cloud session. + + ############################################################################ + # IMPORTANT: The cloud browser continues accruing costs until the session # + # is stopped or times out. To avoid unexpected costs, make sure to stop # + # the session when you're done. # + ############################################################################ + if __name__ == "__main__": asyncio.run(main()) diff --git a/examples/remote_browser.py b/examples/remote_browser.py index ee17cbb..2686bc2 100644 --- a/examples/remote_browser.py +++ b/examples/remote_browser.py @@ -9,7 +9,29 @@ async def main() -> None: # https://app.narada.ai/initialize. browser_window_id = "REPLACE_WITH_BROWSER_WINDOW_ID" - window = RemoteBrowserWindow(browser_window_id=browser_window_id) + # Optional: If the window was launched as a cloud browser session, provide its session ID to + # enable additional management capabilities such as stopping the session: + # + # ``` + # win_1 = await narada.open_and_initialize_cloud_browser_window(...) + # + # browser_window_id = win_1.browser_window_id + # cloud_browser_session_id = win_1.cloud_browser_session_id + # + # ... + # + # win_2 = RemoteBrowserWindow( + # browser_window_id=browser_window_id, + # loud_browser_session_id=cloud_browser_session_id, + # ) + # await win_2.close() # This will stop the cloud session. + # ``` + cloud_browser_session_id = None + + window = RemoteBrowserWindow( + browser_window_id=browser_window_id, + cloud_browser_session_id=cloud_browser_session_id, + ) # Run a task on another machine. response = await window.agent( diff --git a/packages/narada/pyproject.toml b/packages/narada/pyproject.toml index 71d1894..af81cc7 100644 --- a/packages/narada/pyproject.toml +++ b/packages/narada/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "narada" -version = "0.1.33a13" +version = "0.1.33a14" description = "Python client SDK for Narada" license = "Apache-2.0" readme = "README.md" diff --git a/packages/narada/src/narada/client.py b/packages/narada/src/narada/client.py index 20f9fff..ec99554 100644 --- a/packages/narada/src/narada/client.py +++ b/packages/narada/src/narada/client.py @@ -60,7 +60,6 @@ class Narada: _console: Console _playwright_context_manager: PlaywrightContextManager | None = None _playwright: Playwright | None = None - _cloud_windows: set[CloudBrowserWindow] def __init__( self, @@ -74,7 +73,6 @@ def __init__( api_key = api_key or os.environ["NARADA_API_KEY"] self._auth_headers = {"x-api-key": api_key} self._console = Console() - self._cloud_windows = set() async def __aenter__(self) -> Narada: await self._validate_sdk_config() @@ -84,11 +82,6 @@ async def __aenter__(self) -> Narada: return self async def __aexit__(self, *args: Any) -> None: - async with asyncio.TaskGroup() as tg: - for cloud_window in self._cloud_windows: - tg.create_task(cloud_window.cleanup()) - self._cloud_windows.clear() - if self._playwright_context_manager is None: return @@ -210,7 +203,7 @@ async def open_and_initialize_cloud_browser_window( async with cleanup_session.post( f"{base_url}/cloud-browser/stop-cloud-browser-session", headers=self._auth_headers, - json={"session_id": session_id}, + json={"session_id": session_id, "status": "failed"}, timeout=aiohttp.ClientTimeout(total=10), ) as resp: if resp.ok: @@ -282,9 +275,6 @@ async def _initialize_cloud_browser_window( auth_headers=self._auth_headers, ) - # Track the window for cleanup in __aexit__ - self._cloud_windows.add(cloud_window) - if config.interactive: self._print_success_message(browser_window_id) diff --git a/packages/narada/src/narada/window.py b/packages/narada/src/narada/window.py index 1be90e5..4ea9050 100644 --- a/packages/narada/src/narada/window.py +++ b/packages/narada/src/narada/window.py @@ -5,7 +5,7 @@ from abc import ABC from http import HTTPStatus from pathlib import Path -from typing import IO, Any, TypeVar, overload +from typing import IO, Any, TypeVar, overload, override import aiohttp from narada_core.actions.models import ( @@ -603,6 +603,7 @@ def __init__( self, *, browser_window_id: str, + cloud_browser_session_id: str | None = None, api_key: str | None = None, auth_headers: dict[str, str] | None = None, ) -> None: @@ -615,6 +616,28 @@ def __init__( base_url=base_url, browser_window_id=browser_window_id, ) + self._cloud_browser_session_id = cloud_browser_session_id + + @property + def cloud_browser_session_id(self) -> str | None: + return self._cloud_browser_session_id + + @override + async def close(self, *, timeout: int | None = None) -> None: + """Closes the browser window. + + If this window is backed by a cloud browser session, this also stops the cloud + session. + """ + if self._cloud_browser_session_id is None: + return await super().close(timeout=timeout) + + await _stop_cloud_browser_session( + base_url=self._base_url, + auth_headers=self._auth_headers, + session_id=self._cloud_browser_session_id, + timeout=timeout, + ) def __str__(self) -> str: return f"RemoteBrowserWindow(browser_window_id={self.browser_window_id})" @@ -646,28 +669,59 @@ def __init__( ) self._session_id = session_id - async def cleanup(self) -> None: - """Stop the cloud browser session.""" - try: - async with aiohttp.ClientSession() as session: - async with session.post( - f"{self._base_url}/cloud-browser/stop-cloud-browser-session", - headers=self._auth_headers, - json={ - "session_id": self._session_id, - }, - timeout=aiohttp.ClientTimeout(total=10), - ) as resp: - if resp.ok: - response_data = await resp.json() - if not response_data.get("success"): - logger.warning( - f"Failed to stop session: {response_data.get('message')}" - ) - else: - logger.warning(f"Failed to stop session: {resp.status}") - except Exception as e: - logger.warning(f"Error calling stop session endpoint: {e}") + @property + def cloud_browser_session_id(self) -> str: + return self._session_id + + @override + async def close(self, *, timeout: int | None = None) -> None: + """Stops the cloud browser session. + + Unlike local browser windows where close() closes a single window, this stops the + entire cloud session since the serverless container manages the browser lifecycle. + """ + await _stop_cloud_browser_session( + base_url=self._base_url, + auth_headers=self._auth_headers, + session_id=self._session_id, + timeout=timeout, + ) + + def __str__(self) -> str: + return ( + "CloudBrowserWindow(" + f"cloud_browser_session_id={self._session_id}, " + f"browser_window_id={self.browser_window_id}" + ")" + ) + + +async def _stop_cloud_browser_session( + *, + base_url: str, + auth_headers: dict[str, str], + session_id: str, + timeout: int | None = None, +) -> None: + try: + async with aiohttp.ClientSession() as session: + async with session.post( + f"{base_url}/cloud-browser/stop-cloud-browser-session", + headers=auth_headers, + json={"session_id": session_id}, + timeout=aiohttp.ClientTimeout(total=timeout or 10), + ) as resp: + if resp.ok: + response_data = await resp.json() + if not response_data.get("success"): + logger.warning( + "Failed to stop session: %s", + response_data.get("message"), + ) + else: + logger.warning("Failed to stop session: %s", resp.status) + except Exception as e: + logger.warning("Error calling stop session endpoint: %s", e) def create_side_panel_url(config: BrowserConfig, browser_window_id: str) -> str: diff --git a/uv.lock b/uv.lock index 4b20d2e..ae276e3 100644 --- a/uv.lock +++ b/uv.lock @@ -312,7 +312,7 @@ wheels = [ [[package]] name = "narada" -version = "0.1.33a13" +version = "0.1.33a14" source = { editable = "packages/narada" } dependencies = [ { name = "aiohttp" },