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
2 changes: 1 addition & 1 deletion AGENTS.md

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions packages/agentenv-protocol/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1128,6 +1128,17 @@ are the profile's template. The base class adds:
The server keeps each token in memory and only its SHA-256, with its user and base address, in
`.agentenv/cdp-tokens.json`, which code in the computer's session can read but not use; an address
outlives a relaunch of the browser and of the server, and the computer's reset ends it.
- **`browser-recording/v1`** on its card, framework-only, with `max_duration_s`, `max_frames` and `frames`:
`start {request_id, upload, frames?}` records under the write namespace grant `upload`, a start again with the
same `request_id` answering the recording it began; `end {recording_id, upload?, reason?}` ends it, under a fresh
grant when one is given, and answers its manifest; `end_all {reason?}` ends every open one. With `frames`, a frame
is a JPEG of the tab an agent last acted in, else of the active tab: one at the start, one after each burst of an
agent's acting entries (CDP commands and MCP calls, not agentenv's own) has gone quiet for 0.5 s and its tab's page
has loaded (up to 5 s), and one at the end, each uploaded as it's taken under `<recording_id>/frames/` and keyed in
the manifest to the entries it follows (`after`); a frame it couldn't take is in the manifest with its `error`. At
the end it uploads the log's lines since its start, actions and tab events, as `log.jsonl`, and `manifest.json`
(`BrowserRecordingManifest`). `start_browser_recording` and `end_browser_recording` run it, registering the frames,
log and manifest as one file artifact universe, and the end fails the run when its log or a frame is missing.
- **Its CDP relay**, at those addresses: `json/version` and `json/list` (also with a trailing slash,
and `json`) answered from the browser, since a browser on a pipe has none, with its WebSocket at the
address the client reached it at; and `devtools/browser`, where each WebSocket connection gets a
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@
from .environment import (ALONE_BYPASS, BLOCK, BROWSER, COMPUTER_BYPASS, OWNED_FLAGS, POLICIES, READY_POLL_S,
SHM_MIN_BYTES, SITE_PROXY_ENV, SITES, SKILL, BrowserSettings, WebsiteBrowserEnvironment,
Window, start_tab_why)
from .recording import BROWSER_RECORDING, BrowserRecordingManifest, BrowserRecordings, RecordedFrame
from .shapes import (BrowserRecord, ClosedTab, Cookie, CookieToSet, Dialog, Download, LaunchEntry, OpenTab,
OriginStorage, PartitionKey, SiteNames, StartTab, StartTabsLaunch, StorageItem, origin_of)

__all__ = ["ACTIVE_CHECK_TIMEOUT_S", "AGENT", "AGENTENV", "ALONE_BYPASS", "BLOCK", "BROWSER", "CAPTURE_TIMEOUT_S",
__all__ = ["ACTIVE_CHECK_TIMEOUT_S", "AGENT", "AGENTENV", "ALONE_BYPASS", "BLOCK", "BROWSER", "BROWSER_RECORDING",
"CAPTURE_TIMEOUT_S",
"COMPUTER_BYPASS", "DOWNLOADS", "EXIT", "FAILED", "HASH_CHUNK_BYTES", "LIVE_START_TAB_TIMEOUT_S", "LOADED",
"MAX_MESSAGE_BYTES", "OWNED_FLAGS", "PAGE_INPUT", "PAGES", "PIN_TIMEOUT_S", "PIPE_IN", "PIPE_OUT",
"POLICIES", "READY_POLL_S", "SHM_MIN_BYTES", "SITE_PROXY_ENV", "SITES", "SKILL", "STOP", "TIMED_OUT",
"UNKNOWN", "UNNAVIGATED_CAPTURE_TIMEOUT_S", "UPLOADS", "BrowserControl", "BrowserSettings", "DevToolsError",
"DevToolsPipe", "WebsiteBrowserEnvironment", "Window", "BrowserRecord", "ClosedTab", "Cookie", "CookieToSet",
"Dialog", "Download", "LaunchEntry", "OpenTab", "OriginStorage", "PartitionKey", "SiteNames", "StartTab",
"StartTabsLaunch", "StorageItem", "origin_of", "start_tab_why"]
"StartTabsLaunch", "StorageItem", "origin_of", "start_tab_why", "BrowserRecordingManifest", "BrowserRecordings",
"RecordedFrame"]
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,13 @@
#: The CDP input a computer's page driver may send to the active tab, and how long its screenshot may take.
PAGE_INPUT = frozenset({"Input.dispatchMouseEvent", "Input.dispatchKeyEvent", "Input.insertText"})
CAPTURE_TIMEOUT_S, UNNAVIGATED_CAPTURE_TIMEOUT_S = 3.0, 1.0
#: A frame's quality as a JPEG, which is a fifth to a tenth of the PNG's size.
FRAME_QUALITY = 80
#: How long a frame's capture may go unanswered before a second capture is sent: Chrome answers a capture of a tab
#: behind the active one only once another capture of it arrives, and then answers both.
NUDGE_S = 0.5
#: How often a wait for a tab to load looks again.
LOAD_POLL_S = 0.1
#: Where an agent's files to upload are, and where downloads land, under the session user's home.
UPLOADS = "Uploads"
DOWNLOADS = "Downloads"
Expand Down Expand Up @@ -97,11 +104,15 @@ def __init__(self, devtools: DevToolsPipe, dialogs: str, viewport: Optional[tupl
#: Each tab's latest capture, which shows while a dialog keeps its page from painting; and, by session, what
#: input sent there waits on to learn a dialog opened.
self._shots: dict[str, str] = {}
#: Each tab's latest frame, the image and its format, which a frame shows again while a dialog keeps it from
#: painting.
self._frames: dict[str, tuple[str, str]] = {}
self._dialog_opened: dict[str, asyncio.Event] = {}
self._pinned: dict[str, asyncio.Event] = {}
self._tasks: set[asyncio.Future] = set()
#: Each start tab loading, by its session.
#: Each start tab loading, by its session; and each tab whose page has committed and not yet loaded.
self._loading: dict[str, _StartLoad] = {}
self._page_loading: set[str] = set()
#: Every http and https origin a page or frame it follows loaded since this server started, and the sessions
#: of agentenv's own pages whose requests it answers itself.
self.origins: set[str] = set()
Expand Down Expand Up @@ -613,6 +624,63 @@ async def capture_page(self) -> dict:
await self._refresh(target)
return {**({"png": png} if png is not None else {"blank": True}), "page": self._state_of(target)}

async def frame(self, target: Optional[str], load_wait_s: float) -> tuple[str, str, Optional[str], Optional[str]]:
"""A frame of the tab ``target``, else, for none or one that's gone, of the active tab: once its page has loaded,
waiting up to ``load_wait_s``, a JPEG of it as it shows, or, with a dialog keeping it from painting, the frame it
last took, else its last capture, a PNG; a tab that closes while its page loads is the active tab's. The image
base64, its format and the tab and its URL; DeviceUnavailable with no tab open, or for one that paints nothing
in time."""
session = next((s for s, t in self._sessions.items() if t == target), None) if target else None
if session is None:
session = await self.active_session()
target = self._sessions[session]
deadline = asyncio.get_running_loop().time() + load_wait_s
while target in self._page_loading and asyncio.get_running_loop().time() < deadline:
await asyncio.sleep(LOAD_POLL_S)
if session not in self._sessions:
session = await self.active_session()
target = self._sessions[session]
if session in self._open_dialogs:
if (earlier := self._frames.get(target)) is None and (shot := self._shots.get(target)) is not None:
earlier = shot, "png"
if earlier is None:
raise DeviceUnavailable("a dialog keeps the tab from painting and it had no capture before it")
return *earlier, target, self.url_of(target)
try:
shot = await self._nudged_capture(session)
except asyncio.TimeoutError:
raise DeviceUnavailable(f"the tab painted nothing within {CAPTURE_TIMEOUT_S:g} s") from None
except (ConnectionError, DevToolsError) as e:
raise DeviceUnavailable(f"the tab couldn't be captured: {e}") from None
self._frames[target] = shot["data"], "jpg"
return shot["data"], "jpg", target, self.url_of(target)
Comment thread
earakely-scale marked this conversation as resolved.
Resolved

async def _nudged_capture(self, session: str) -> dict:
"""The tab's JPEG from the first of its captures to answer: a second follows one unanswered after ``NUDGE_S``,
since a tab behind the active one answers only then, all within ``CAPTURE_TIMEOUT_S``."""
loop = asyncio.get_running_loop()
deadline = loop.time() + CAPTURE_TIMEOUT_S

def capture() -> asyncio.Future:
return asyncio.ensure_future(self.devtools.call("Page.captureScreenshot", {
"format": "jpeg", "quality": FRAME_QUALITY, "optimizeForSpeed": True}, session_id=session,
timeout_s=max(0.0, deadline - loop.time())))

captures = {capture()}
try:
done, _ = await asyncio.wait(captures, timeout=NUDGE_S)
if not done:
captures.add(capture())
while True:
done, captures = await asyncio.wait(captures, return_when=asyncio.FIRST_COMPLETED)
if answers := [c.result() for c in done if c.exception() is None]:
return answers[0]
if not captures:
raise next(iter(done)).exception()
finally:
for pending in captures:
pending.cancel()

async def _capture(self, session: str) -> Optional[str]:
"""The tab's page as it shows now; with a dialog open on it, which keeps it from painting, the last it took."""
target = self._sessions[session]
Expand Down Expand Up @@ -727,6 +795,8 @@ def _on_Page_frameNavigated(self, params: dict, session: Optional[str]) -> None:
if session in self._sessions:
self._saw(params.get("frame", {}).get("url", ""))
frame = params.get("frame", {})
if not frame.get("parentId") and (target := self._sessions.get(session)) is not None:
self._page_loading.add(target)
if self.log is not None and not frame.get("parentId") and (target := self._sessions.get(session)) in self.tabs:
self._spawn(self._log_navigated(self.log.reserve(), log_time(), target, frame.get("url", ""),
frame.get("loaderId")))
Expand Down Expand Up @@ -807,13 +877,16 @@ async def _let_run(self, session: str) -> None:
def _on_Target_detachedFromTarget(self, params: dict, session: Optional[str]) -> None:
if (target := self._sessions.pop(params["sessionId"], None)) is not None:
self._shots.pop(target, None)
self._frames.pop(target, None)
self._pinned.pop(target, None)
self._page_loading.discard(target)
self._choosers.pop(params["sessionId"], None)
self._dialog_opened.pop(params["sessionId"], None)

def _on_Page_loadEventFired(self, params: dict, session: Optional[str]) -> None:
self._choosers.pop(session, None)
if (target := self._sessions.get(session)) is not None:
self._page_loading.discard(target)
self._spawn(self._refresh(target))
if (load := self._loading.get(session)) is not None and load.commits:
load.loaded.add(load.commits[-1])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
from . import data
from .control import AGENT, DOWNLOADS, EXIT, LIVE_START_TAB_TIMEOUT_S, LOADED, STOP, UPLOADS, BrowserControl, _now
from .devtools import PIPE_IN, PIPE_OUT, DevToolsError, DevToolsPipe
from .recording import BROWSER_RECORDING, MAX_DURATION_S, MAX_FRAMES, BrowserRecordings
from .shapes import BrowserRecord, BrowserState, CookieToSet, OriginStorage, SiteNames, StateToLoad

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -155,6 +156,8 @@ def __init__(self) -> None:
self.cdp_tokens = Tokens(SOCKET.parent / "cdp-tokens.json")
self.relay = Relay(self.devtools, dialogs_answered=self.settings.dialogs != AGENT, log=self.log,
control=self.control)
#: Its recordings: frames of the tab an agent last acted in, and its log, uploaded under each one's grant.
self.recordings = BrowserRecordings(self.log, self.control)
self.devtools.on_close(self._pipe_closed)

@classmethod
Expand Down Expand Up @@ -372,6 +375,25 @@ async def browser_calls_end(self, entry: int, ok: bool, error: Optional[str] = N
raise InvalidArgument(f"no tool call is open as entry {entry}")
return {}

@extension(BROWSER_RECORDING, op="start")
async def browser_recording_start(self, request_id: str, upload: dict, frames: bool = True) -> dict:
"""A recording of this browser, its frames and its log uploaded under the write namespace grant ``upload``:
with ``frames``, a frame of the tab an agent last acted in at the start, after each burst of its actions
settles and at the end. A start again with the same ``request_id`` answers the recording it began."""
return await self.recordings.start(request_id, upload, frames)

@extension(BROWSER_RECORDING, op="end")
async def browser_recording_end(self, recording_id: str, upload: Optional[dict] = None,
reason: str = "end") -> dict:
"""The recording ``recording_id`` ended, under a fresh grant ``upload`` when one is given: its last frames,
its log's lines since its start and its manifest uploaded, and the manifest answered."""
return await self.recordings.end(recording_id, upload, reason)

@extension(BROWSER_RECORDING, op="end_all")
async def browser_recording_end_all(self, reason: str = "teardown") -> dict:
"""Every open recording ended, under its start's grant, as the env closes or resets: their manifests."""
return {"recordings": await self.recordings.end_all(reason)}
Comment thread
earakely-scale marked this conversation as resolved.
Resolved

@extension(BROWSER, op="cdp")
async def browser_cdp(self, user: str, request: Request, origin: Optional[str] = None) -> dict:
"""``user``'s CDP address once the browser's ready, the same one each time it's asked for, as the server that
Expand Down Expand Up @@ -649,6 +671,8 @@ async def _start_tabs_settled(self) -> list[dict]:
return self.start_tab_launches[-1]["tabs"] if self.start_tab_launches else []

def extension_params(self, uri: str) -> dict:
if uri == BROWSER_RECORDING:
return {"max_duration_s": MAX_DURATION_S, "max_frames": MAX_FRAMES, "frames": True}
if uri == BROWSER:
params: dict[str, Any] = {"chromium": self.version, "clock": "real", "mode": _mode()}
if _alone():
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from collections.abc import Mapping
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Optional
from typing import Any, Callable, Optional
from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit

logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -314,6 +314,8 @@ def __init__(self, path: Path) -> None:
self._unwritable = False
#: Per tab, the sequence number of the latest acting entry on it.
self.last_acting: dict[str, int] = {}
#: Each told every action as it's written, its sequence number and its fields, as a recording follows them.
self.watchers: list[Callable[[int, dict], None]] = []

@classmethod
def for_app(cls, name: str, fallback: Path) -> BrowserLog:
Expand Down Expand Up @@ -381,6 +383,16 @@ def finish(self, begun: dict, **fields: Any) -> None:
self.note("outcome", {"entry": seq, **fields})
else:
self.write(seq, {"action": action}, at=begun["at"])
for watcher in self.watchers:
try:
watcher(seq, action)
except Exception:
logger.exception("a watcher of the browser's log failed")

@property
def next_seq(self) -> int:
"""The sequence number the next line will take."""
return self._seq + 1

def note(self, kind: str, body: dict) -> int:
"""A line of ``kind`` written now: a ``tab`` event, or a ``reset``."""
Expand Down
Loading
Loading