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
13 changes: 7 additions & 6 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ On the first step, the browser agent gives the LLM:
- current URL
- page title
- open browser tabs
- useful ARIA controls
- useful ARIA controls with temporary element refs
- limited visible page text
- screenshot of the visible browser area

Expand All @@ -131,8 +131,8 @@ Current page:
youtube.com

Controls:
textbox "Search"
button "Search"
[e4] textbox "Search"
[e5] button "Search"

Screenshot:
current YouTube page
Expand All @@ -148,8 +148,7 @@ Example:
{
"action": "type",
"arguments": {
"role": "textbox",
"name": "Search",
"ref": "e4",
"text": "JavaScript tutorials",
"press_enter": true
},
Expand All @@ -158,7 +157,9 @@ Example:
}
```

Chexsa performs that action, reads the browser again, compares it with the previous state, and repeats until the model says the task is complete.
Element refs are temporary. Chexsa maps each ref back to the exact Playwright element from the latest observation. This lets duplicate controls such as multiple "Send" buttons remain separately targetable.

Chexsa performs the action, reads the browser again, compares it with the previous state, creates fresh refs, and repeats until the model says the task is complete.

---

Expand Down
69 changes: 24 additions & 45 deletions browser_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
from pathlib import Path
from typing import Any, Callable

from playwright.sync_api import Page
from playwright.sync_api import Locator, Page


# BrowserActions asks BrowserState for the page that is currently open.
GetPageFn = Callable[[], Page]
SetPageFn = Callable[[Page], None]
GetElementFn = Callable[[str], Locator]


class BrowserActions:
Expand All @@ -18,9 +18,11 @@ def __init__(
self,
get_page: GetPageFn,
set_page: SetPageFn,
get_element: GetElementFn,
) -> None:
self.get_page = get_page
self.set_page = set_page
self.get_element = get_element

def execute(
self,
Expand Down Expand Up @@ -51,7 +53,6 @@ def execute(

return actions[action](**arguments)


def batch(self, actions: list[dict[str, Any]]) -> list[Any]:
"""Run simple actions that do not change the page."""

Expand Down Expand Up @@ -84,65 +85,60 @@ def navigate(self, url: str) -> str:

return page.url

def click(self, role: str, name: str) -> str:
"""Click an element using its ARIA role and visible name."""
def click(self, ref: str) -> str:
"""Click the browser element from the latest observation."""

element = self._get_element(role, name)
element.click()
self.get_element(ref).click()

return f'Clicked {role} "{name}"'
return f"Clicked {ref}"

def type_text(
self,
role: str,
name: str,
ref: str,
text: str,
press_enter: bool = False,
) -> str:
"""Put text into an input found by its role and name."""
"""Put text into a browser element from the latest observation."""

element = self._get_element(role, name)
element = self.get_element(ref)
element.fill(text)

if press_enter:
element.press("Enter")

return f'Entered text into {role} "{name}"'
return f"Entered text into {ref}"

def press_key(self, key: str) -> str:
"""Press a keyboard key on the current page."""

page = self.get_page()
page.keyboard.press(key)
self.get_page().keyboard.press(key)

return f"Pressed {key}"

def select_option(
self,
role: str,
name: str,
ref: str,
option: str,
) -> str:
"""Choose an option from a select control."""
"""Choose an option from a browser element."""

element = self._get_element(role, name)
element.select_option(label=option)
self.get_element(ref).select_option(label=option)

return f'Selected "{option}" from {role} "{name}"'
return f'Selected "{option}" from {ref}'

def check(self, role: str, name: str) -> str:
def check(self, ref: str) -> str:
"""Check a checkbox or radio control."""

self._get_element(role, name).check()
self.get_element(ref).check()

return f'Checked {role} "{name}"'
return f"Checked {ref}"

def uncheck(self, role: str, name: str) -> str:
def uncheck(self, ref: str) -> str:
"""Uncheck a checkbox."""

self._get_element(role, name).uncheck()
self.get_element(ref).uncheck()

return f'Unchecked {role} "{name}"'
return f"Unchecked {ref}"

def go_back(self) -> str:
"""Go back one page in browser history."""
Expand Down Expand Up @@ -197,27 +193,10 @@ def switch_tab(self, index: int) -> str:
def scroll(self, amount: int = 600) -> str:
"""Scroll the current page up or down."""

page = self.get_page()

# Positive scrolls down; negative scrolls up.
page.mouse.wheel(0, amount)
self.get_page().mouse.wheel(0, amount)

return f"Scrolled {amount} pixels"


def _get_element(self, role: str, name: str):
"""Find one browser element by role and name."""

element = self.get_page().get_by_role(role, name=name)
count = element.count()

if count != 1:
raise RuntimeError(
f'Expected one {role} "{name}", found {count}.'
)

return element

def upload_file(
self,
selector: str,
Expand Down
Loading
Loading