Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/askui/prompts/act_prompts.py
Original file line number Diff line number Diff line change
Expand Up @@ -193,8 +193,11 @@
WEB_BROWSER_CAPABILITIES = """You are an autonomous AI agent that can interact
with web interfaces through computer vision and browser control.

* You are utilizing a webbrowser in full-screen mode. So you are only
seeing the content of the currently opened webpage (tab).
* You are utilizing a webbrowser in full-screen mode. You can see and
interact with one tab at a time — the currently active tab.
* You can manage multiple browser tabs using the list_tabs, switch_tab,
and close_tab tools. When a link opens a new tab, use list_tabs to find
it and switch_tab to move to it if needed.
* Your primary goal is to execute tasks efficiently and reliably.
* Operate independently and make informed decisions without requiring
user input.
Expand Down Expand Up @@ -228,7 +231,7 @@
* Test Device: Yes, with full permissions"""

WEB_AGENT_DEVICE_INFORMATION = """* Environment: Web browser in full-screen mode
* Visibility: Only current webpage content (single tab)
* Visibility: Active tab content (use list_tabs / switch_tab to manage multiple tabs)
* Interaction: Mouse, keyboard, and browser-specific controls"""

# Report Format
Expand Down
192 changes: 188 additions & 4 deletions src/askui/tools/playwright/agent_os.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class PlaywrightAgentOs(ComputerAgentOS):

This implementation uses Playwright's Python SDK to control browser automation
and simulate user interactions. It provides mouse control, keyboard input,
and screen capture functionality through a browser context.
screen capture, and multi-tab management functionality through a browser context.

Args:
reporter (Reporter, optional): Reporter used for reporting. Defaults to
Expand All @@ -96,6 +96,11 @@ class PlaywrightAgentOs(ComputerAgentOS):
When ``None``, downloads are left in Playwright's temporary location
(and deleted when the browser closes). The directory is created if it
does not exist. Defaults to `None`.
auto_follow_new_tab (bool, optional): When `True`, any new tab opened by the
browser (e.g. via ``target="_blank"`` links or ``window.open()``)
automatically becomes the active tab. When `False`, new tabs are tracked
but the active tab does not change; use `switch_tab()` to move to them
manually. Defaults to `True`.
"""

_REPORTER_ROLE_NAME: str = "PlaywrightAgentOS"
Expand All @@ -110,6 +115,7 @@ def __init__(
install_browser: bool = True,
install_dependencies: bool = False,
download_dir: str | Path | None = None,
auto_follow_new_tab: bool = True,
) -> None:
self._browser_type = browser_type
self._headless = headless
Expand All @@ -118,14 +124,20 @@ def __init__(
self._install_browser = install_browser
self._install_dependencies = install_dependencies
self._download_dir = Path(download_dir) if download_dir is not None else None
self._auto_follow_new_tab = auto_follow_new_tab

# Playwright objects
self._playwright: Playwright | None = None
self._browser: Browser | None = None
self._context: BrowserContext | None = None
self._page: Page | None = None
self._pages: list[Page] = []
self._reporter: Reporter = reporter

# Set to True by _on_new_page when a new tab is followed; cleared by
# _sync_pages() after calling bring_to_front() on the main thread.
self._needs_bring_to_front: bool = False

# Event listening state
self._listening = False
self._event_queue: list[InputEvent] = []
Expand Down Expand Up @@ -224,15 +236,91 @@ def connect(self) -> None:
no_viewport=True,
)

self._pages = []
self._page = self._context.new_page()
self._pages.append(self._page)
self._page.on("download", self._on_download)
self._context.on("page", self._on_new_page)
# Navigate to a blank page to ensure we have a working page
self._page.goto("data:text/html,<html><body><h1>Starting...</h1></body></html>")
self._reporter.add_message(
self._REPORTER_ROLE_NAME,
"Connected to playwright browser",
)

def _on_new_page(self, page: Page) -> None:
"""Track a newly opened browser tab.

Registered as a ``page`` event handler on the `BrowserContext`. Called
from Playwright's background asyncio thread whenever a new tab is opened
(e.g. via ``target="_blank"`` links or ``window.open()``).

Only thread-safe operations are performed here: appending to the tracked
list and reassigning ``self._page``. Sync Playwright methods such as
``bring_to_front()`` must NOT be called here — they require the main
greenlet context and deadlock when invoked from the background thread.
Auto-follow (including ``bring_to_front()``) is completed on the main
thread by `_sync_pages`, which is called at the start of every
`screenshot`.

Args:
page (Page): The newly opened Playwright page.
"""
page.on("download", self._on_download)
self._pages.append(page)
if self._auto_follow_new_tab:
self._page = page
# Signal _sync_pages() to call bring_to_front() on the main thread.
# bring_to_front() is deliberately NOT called here: even though
# Playwright's EventGreenlet supports sync calls, any exception it
# raises would be silently deferred and re-raised at the next API
# call, which could fail an unrelated operation such as screenshot().
self._needs_bring_to_front = True
self._reporter.add_message(
self._REPORTER_ROLE_NAME,
f"New tab opened: '{page.url}'",
)

def _sync_pages(self) -> None:
"""Sync tracked pages with the live browser context, main-thread safe.

Called at the start of every ``screenshot`` (after ``_pump_event_loop``
has flushed all pending page events). Does three things:

1. Picks up any pages that ``_on_new_page`` could not track because of
the race between the dispatcher firing the event and ``screenshot``
being called — after the pump the event has already fired, so this
acts as a safety net rather than the primary detection path.
2. Prunes pages that have been closed since the last call.
3. Calls ``bring_to_front()`` on the main thread when
``_needs_bring_to_front`` is set. The flag is raised by
``_on_new_page`` instead of calling ``bring_to_front()`` there
directly, because a deferred exception from inside an EventGreenlet
would be re-raised at the next API call and silently break
unrelated operations.
"""
if self._context is None:
return

known_ids = {id(p) for p in self._pages}
for page in self._context.pages:
if id(page) not in known_ids:
page.on("download", self._on_download)
self._pages.append(page)
known_ids.add(id(page))
if self._auto_follow_new_tab:
self._page = page
self._needs_bring_to_front = True

# Prune closed pages
self._pages = [p for p in self._pages if not p.is_closed()]
if self._page is not None and self._page.is_closed():
self._page = self._pages[-1] if self._pages else None

if self._needs_bring_to_front and self._page is not None:
self._needs_bring_to_front = False
self._page.bring_to_front()

def _on_download(self, download: Download) -> None:
"""Register a started download for deterministic saving.

Expand Down Expand Up @@ -380,9 +468,9 @@ def disconnect(self) -> None:
# the copy and leaves a truncated file on disk.
self._deliver_and_flush_downloads()

if self._page:
self._page.close()
self._page = None
# Clear page tracking; context.close() handles actual page teardown.
self._pages.clear()
self._page = None

if self._context:
self._context.close()
Expand Down Expand Up @@ -421,6 +509,13 @@ def screenshot(self, report: bool = True, unscaled: bool = False) -> Image.Image
error_msg = "No active page. Call connect() first."
raise RuntimeError(error_msg)

# Pump the event loop before syncing so any pending "page" events
# (new tabs opened by the previous action) are processed first.
# Without this, the new-page event fires during page.screenshot()
# after the CDP command has already been sent to the old page.
self._pump_event_loop(0)
self._sync_pages()

screenshot_bytes = self._page.screenshot(scale="css")
screenshot = Image.open(io.BytesIO(screenshot_bytes))
# Taking the screenshot pumps the event loop, so any download that
Expand Down Expand Up @@ -705,6 +800,95 @@ def retrieve_active_display(self) -> Display:
),
)

# --- Tab management ---

def list_tabs(self) -> list[dict[str, int | str]]:
"""Return metadata for every currently open browser tab.

Returns:
list[dict[str, int | str]]: One entry per open tab, each containing:

- ``index`` (int): Zero-based tab index used by `switch_tab` and
`close_tab`.
- ``title`` (str): Page title (empty string if unavailable).
- ``url`` (str): Current URL of the page.
"""
tabs: list[dict[str, int | str]] = []
for i, page in enumerate(self._pages):
try:
title: str = page.title()
except Exception: # noqa: BLE001
title = ""
tabs.append({"index": i, "title": title, "url": page.url})
return tabs

def switch_tab(self, index: int) -> None:
"""Switch the active browser tab to the tab at ``index``.

Args:
index (int): Zero-based index of the tab to activate (see
`list_tabs` for available indices).

Raises:
RuntimeError: If no browser session is active.
IndexError: If ``index`` is out of range.
"""
if not self._pages:
error_msg = "No open tabs. Call connect() first."
raise RuntimeError(error_msg)
if index < 0 or index >= len(self._pages):
error_msg = (
f"Tab index {index} is out of range. "
f"Available indices: 0-{len(self._pages) - 1}."
)
raise IndexError(error_msg)
self._page = self._pages[index]
self._page.bring_to_front()
self._reporter.add_message(
self._REPORTER_ROLE_NAME,
f"switch_tab(index={index}) -> '{self._page.url}'",
)

def close_tab(self, index: int) -> None:
"""Close the browser tab at ``index``.

When the closed tab was the active one, the agent automatically
switches to the nearest remaining tab (the tab at ``index - 1``, or
the new last tab when ``index`` was the last one).

Args:
index (int): Zero-based index of the tab to close (see `list_tabs`
for available indices).

Raises:
RuntimeError: If no browser session is active or if only one tab
remains (closing it would leave the browser with no pages).
IndexError: If ``index`` is out of range.
"""
if not self._pages:
error_msg = "No open tabs. Call connect() first."
raise RuntimeError(error_msg)
if len(self._pages) <= 1:
error_msg = "Cannot close the last remaining tab."
raise RuntimeError(error_msg)
if index < 0 or index >= len(self._pages):
error_msg = (
f"Tab index {index} is out of range. "
f"Available indices: 0-{len(self._pages) - 1}."
)
raise IndexError(error_msg)
page_to_close = self._pages.pop(index)
was_active = self._page is page_to_close
page_to_close.close()
if was_active:
new_index = min(index, len(self._pages) - 1)
self._page = self._pages[new_index]
self._page.bring_to_front()
self._reporter.add_message(
self._REPORTER_ROLE_NAME,
f"close_tab(index={index})",
)

def _convert_key(self, key: PcKey | ModifierKey) -> str:
"""
Convert our key format to Playwright's key format.
Expand Down
86 changes: 86 additions & 0 deletions src/askui/tools/playwright/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -544,3 +544,89 @@ def __init__(self, agent_os: PlaywrightAgentOs | None = None) -> None:
def __call__(self) -> str:
url = self.agent_os.get_page_url()
return f"Current page URL: {url}"


class PlaywrightListTabsTool(PlaywrightBaseTool):
"""Lists all open browser tabs with their index, title, and URL."""

def __init__(self, agent_os: PlaywrightAgentOs | None = None) -> None:
super().__init__(
name="list_tabs",
description=(
"List all currently open browser tabs. "
"Returns the index, title, and URL of each tab. "
"Use the index with switch_tab or close_tab."
),
agent_os=agent_os,
)

@override
def __call__(self) -> str:
tabs = self.agent_os.list_tabs()
if not tabs:
return "No open tabs."
lines = [f"[{t['index']}] {t['title']} — {t['url']}" for t in tabs]
return "Open tabs:\n" + "\n".join(lines)


class PlaywrightSwitchTabTool(PlaywrightBaseTool):
"""Switches the active browser tab to a tab at a given index."""

def __init__(self, agent_os: PlaywrightAgentOs | None = None) -> None:
super().__init__(
name="switch_tab",
description=(
"Switch to a different browser tab by its index. "
"Use list_tabs to see all open tabs and their indices."
),
input_schema={
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": (
"Zero-based index of the tab to switch to (from list_tabs)."
),
},
},
"required": ["index"],
},
agent_os=agent_os,
)

@override
def __call__(self, index: int) -> str:
self.agent_os.switch_tab(index)
return f"Switched to tab {index}."


class PlaywrightCloseTabTool(PlaywrightBaseTool):
"""Closes a browser tab by its index."""

def __init__(self, agent_os: PlaywrightAgentOs | None = None) -> None:
super().__init__(
name="close_tab",
description=(
"Close a browser tab by its index. "
"Use list_tabs to see available tabs. "
"The last remaining tab cannot be closed."
),
input_schema={
"type": "object",
"properties": {
"index": {
"type": "integer",
"description": (
"Zero-based index of the tab to close (from list_tabs)."
),
},
},
"required": ["index"],
},
agent_os=agent_os,
)

@override
def __call__(self, index: int) -> str:
self.agent_os.close_tab(index)
return f"Tab {index} was closed."
Loading
Loading