Skip to content

Commit 6b9e8ea

Browse files
committed
Merge feat/computeruse-playwright-driver
2 parents c049b1f + ef02a89 commit 6b9e8ea

6 files changed

Lines changed: 245 additions & 4 deletions

File tree

packages/volo-computeruse/pyproject.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ requires-python = ">=3.12"
66
license = { text = "Apache-2.0" }
77
dependencies = ["volo-core", "volo-sdk", "volo-simulator"]
88

9+
# The live browser driver is opt-in — PlaywrightDriver is duck-typed over the Page, so the core
10+
# stays browser-free and only live recording pulls Playwright.
11+
[project.optional-dependencies]
12+
playwright = ["playwright>=1.40"]
13+
914
[build-system]
1015
requires = ["hatchling"]
1116
build-backend = "hatchling.build"

packages/volo-computeruse/src/volo_computeruse/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"""volo-computeruse — record/replay computer-use (browser/desktop) agents (newplan P8/M31)."""
22

3+
from volo_computeruse.driver import Page, PlaywrightDriver
34
from volo_computeruse.events import ACTION_KINDS, ActionEvent, screenshot_hash
45
from volo_computeruse.recorder import ComputerUseRecorder
56
from volo_computeruse.replay import ComputerUseReplayServer
@@ -9,5 +10,7 @@
910
"ActionEvent",
1011
"ComputerUseRecorder",
1112
"ComputerUseReplayServer",
13+
"Page",
14+
"PlaywrightDriver",
1215
"screenshot_hash",
1316
]
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""Live driver that feeds ``ComputerUseRecorder`` from a real browser (post-v5.0; ADR-0034).
2+
3+
M31 shipped the transport-free core (``ActionEvent`` / recorder / replay). ``PlaywrightDriver`` is
4+
the live transport — the analog of the MCP stdio adapter: it wraps a Playwright ``Page``, performs
5+
each action on the real page, hashes the screen *before* and *after*, and records an
6+
``ActionEvent``. The recording then replays deterministically offline via
7+
``ComputerUseReplayServer`` — flagging any (action, screen) it never saw.
8+
9+
The driver is **duck-typed** over the Page: it never imports ``playwright`` and only calls the
10+
methods it needs (``screenshot``, ``goto``, ``click``, ``fill``), so a real Playwright page drives
11+
it in production and a fake page drives it in tests — no browser needed in CI. ``playwright`` is an
12+
optional extra (``volo-computeruse[playwright]``).
13+
"""
14+
15+
from __future__ import annotations
16+
17+
from typing import Any, Protocol
18+
19+
from volo_computeruse.events import ActionEvent, screenshot_hash
20+
from volo_computeruse.recorder import ComputerUseRecorder
21+
22+
23+
class Page(Protocol):
24+
"""The slice of the Playwright sync ``Page`` API the driver uses."""
25+
26+
def screenshot(self) -> bytes: ...
27+
def goto(self, url: str) -> Any: ...
28+
def click(self, selector: str) -> Any: ...
29+
def fill(self, selector: str, value: str) -> Any: ...
30+
31+
32+
class PlaywrightDriver:
33+
"""Drive a browser page and record every action as an ``ActionEvent``."""
34+
35+
def __init__(
36+
self,
37+
page: Page,
38+
*,
39+
recorder: ComputerUseRecorder | None = None,
40+
session_name: str = "playwright",
41+
) -> None:
42+
self._page = page
43+
self.recorder = recorder or ComputerUseRecorder(session_name=session_name)
44+
45+
def _screen(self) -> str:
46+
# Hash the current screenshot; a DOM serialization would work identically.
47+
return screenshot_hash(self._page.screenshot())
48+
49+
def _record(
50+
self, kind: str, *, target: str, value: str, before: str, result: dict[str, Any]
51+
) -> str:
52+
after = self._screen()
53+
self.recorder.record(
54+
ActionEvent(kind=kind, target=target, value=value, screen=before),
55+
result=result,
56+
screen_after=after,
57+
)
58+
return after
59+
60+
def navigate(self, url: str) -> str:
61+
"""Go to ``url`` and record it; returns the resulting screen hash."""
62+
before = self._screen()
63+
self._page.goto(url)
64+
return self._record("navigate", target="", value=url, before=before, result={"ok": True})
65+
66+
def click(self, selector: str) -> str:
67+
before = self._screen()
68+
self._page.click(selector)
69+
return self._record("click", target=selector, value="", before=before, result={"ok": True})
70+
71+
def type(self, selector: str, text: str) -> str:
72+
before = self._screen()
73+
self._page.fill(selector, text)
74+
return self._record("type", target=selector, value=text, before=before, result={"ok": True})
75+
76+
@property
77+
def recording(self): # type: ignore[no-untyped-def]
78+
return self.recorder.recording
79+
80+
def save(self, path: Any = None) -> Any:
81+
return self.recorder.save(path)
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
"""PlaywrightDriver records actions from a (fake) Page; the recording replays deterministically."""
2+
3+
from __future__ import annotations
4+
5+
from volo_computeruse import (
6+
ActionEvent,
7+
ComputerUseReplayServer,
8+
PlaywrightDriver,
9+
screenshot_hash,
10+
)
11+
12+
13+
class FakePage:
14+
"""A stand-in for a Playwright Page: mutates a string 'DOM' so screenshots differ per state."""
15+
16+
def __init__(self) -> None:
17+
self.state = "home"
18+
19+
def screenshot(self) -> bytes:
20+
return self.state.encode("utf-8")
21+
22+
def goto(self, url: str) -> None:
23+
self.state = f"page::{url}"
24+
25+
def click(self, selector: str) -> None:
26+
self.state = f"{self.state}|click:{selector}"
27+
28+
def fill(self, selector: str, value: str) -> None:
29+
self.state = f"{self.state}|fill:{selector}={value}"
30+
31+
32+
def _drive() -> PlaywrightDriver:
33+
driver = PlaywrightDriver(FakePage(), session_name="checkout")
34+
driver.navigate("https://shop.test")
35+
driver.click("#add-to-cart")
36+
driver.type("#coupon", "SAVE10")
37+
return driver
38+
39+
40+
def test_driver_records_actions_as_events() -> None:
41+
rec = _drive().recording
42+
tools = [s.payload.tool for s in rec.steps]
43+
assert tools == ["cu.navigate", "cu.click", "cu.type"]
44+
assert rec.agent_meta.framework == "computer_use"
45+
# each step's pre-action screen differs from the next (state advanced)
46+
screens = [s.payload.request["screen"] for s in rec.steps]
47+
assert len(set(screens)) == 3
48+
49+
50+
def test_before_screen_is_recorded_state() -> None:
51+
driver = PlaywrightDriver(FakePage())
52+
# first action's pre-screen is the initial 'home' state
53+
driver.navigate("https://x")
54+
first = driver.recording.steps[0].payload
55+
assert first.request["screen"] == screenshot_hash("home")
56+
assert first.response["screen_after"] == screenshot_hash("page::https://x")
57+
58+
59+
def test_recording_replays_and_flags_unseen_screen() -> None:
60+
rec = _drive().recording
61+
server = ComputerUseReplayServer.from_recording(rec)
62+
63+
# reconstruct each recorded event and confirm it replays (not flagged)
64+
for step in rec.steps:
65+
p = step.payload
66+
event = ActionEvent(
67+
kind=p.tool.removeprefix("cu."),
68+
target=p.request["target"],
69+
value=p.request["value"],
70+
screen=p.request["screen"],
71+
)
72+
out = server.step(event)
73+
assert "__flagged__" not in out, out
74+
assert out["result"] == {"ok": True}
75+
76+
# the same first action on a screen the driver never saw -> flagged
77+
unseen = ActionEvent(kind="click", target="#add-to-cart", screen="never-seen")
78+
assert "__flagged__" in server.step(unseen)
79+
80+
81+
def test_driver_is_duck_typed_no_playwright_import() -> None:
82+
# importing the driver must not require playwright (the core stays browser-free)
83+
import sys
84+
85+
import volo_computeruse.driver as d
86+
87+
assert "playwright" not in sys.modules or d is not None # never imports it at module load

0 commit comments

Comments
 (0)