Skip to content
Open
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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -382,13 +382,17 @@ stagehand = await Stagehand(...).init()
page = wrap_stagehand(stagehand.page, tracer, name="my run")

await page.goto("https://example.com")
await page.observe("find the login button") # auto-recorded
await page.act("click the login button") # auto-recorded
await page.extract("get the headline") # auto-recorded
page.bt_run.close()
```

The wrapper records method args/kwargs as `model_input`, then writes the
successful Stagehand return value back to the same step as `model_output`.
It also summarizes Stagehand-shaped evidence such as observe candidates,
selectors, extracted text, action/status fields, retries, and verification or
tool-call metadata under `model_output["stagehand_evidence"]`.
For Stagehand `act` and `extract` debugging, see
[Debug Stagehand runs with BrowserTrace](https://aaronlab.github.io/browsertrace/stagehand-debugging.html).

Expand Down
82 changes: 71 additions & 11 deletions browsertrace/integrations/stagehand.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

from __future__ import annotations

from collections.abc import Mapping
from typing import Any, Optional

from ..tracer import Run, Tracer
Expand Down Expand Up @@ -62,6 +63,8 @@ async def traced(*args: Any, **kwargs: Any) -> Any:
url=getattr(self._page, "url", "") or "",
screenshot=shot,
model_input={"method": name, "args": list(args), "kwargs": kwargs},
stagehand_method=name,
instruction=str(instr),
)
try:
result = await fn(*args, **kwargs)
Expand All @@ -72,7 +75,7 @@ async def traced(*args: Any, **kwargs: Any) -> Any:
raise
output = _serialize_result(result)
model_output = {"result": output}
evidence = _extract_stagehand_evidence(output)
evidence = _extract_stagehand_evidence(output, method=name)
if evidence:
model_output["stagehand_evidence"] = evidence
self.bt_run.update_step(step_id, model_output=model_output)
Expand All @@ -93,39 +96,96 @@ def wrap_stagehand(page: Any, tracer: Tracer, name: str = "stagehand run") -> _T


def _serialize_result(result: Any) -> Any:
if hasattr(result, "model_dump"):
return _to_plain(result, set())


def _to_plain(value: Any, seen: set[int]) -> Any:
if isinstance(value, (str, int, float, bool)) or value is None:
return value
if isinstance(value, bytes):
return repr(value)
value_id = id(value)
if value_id in seen:
return "<recursive>"
seen.add(value_id)

if hasattr(value, "model_dump"):
try:
return result.model_dump(exclude_none=True)
return _to_plain(value.model_dump(exclude_none=True), seen)
except Exception:
pass
if hasattr(result, "dict"):
if hasattr(value, "dict"):
try:
return result.dict()
return _to_plain(value.dict(), seen)
except Exception:
pass
if isinstance(result, (dict, list, tuple, str, int, float, bool)) or result is None:
return result
if hasattr(result, "__dict__"):
return vars(result)
return str(result)
if isinstance(value, Mapping):
return {str(key): _to_plain(child, seen) for key, child in value.items()}
if isinstance(value, (list, tuple, set)):
return [_to_plain(child, seen) for child in value]
if hasattr(value, "__dict__"):
return _to_plain(vars(value), seen)
return str(value)


def _extract_stagehand_evidence(output: Any) -> dict[str, Any]:
def _extract_stagehand_evidence(output: Any, *, method: str = "") -> dict[str, Any]:
evidence: dict[str, Any] = {}
selectors = _find_values(output, "selector", "selectors", "css_selector", "cssSelector")
descriptions = _find_values(output, "description")
methods = _find_values(output, "method")
elements = _find_values(output, "element", "elements", "target", "targets")
extracted_text = _find_values(
output, "text", "content", "extracted_text", "extractedText", "extracted_content"
)
actions = _find_values(output, "action", "actions")
statuses = _find_values(output, "status", "success", "ok")
attempts = _find_values(output, "attempt", "attempts", "retry_count", "retries")
verifications = _find_values(
output, "verification", "verified", "validation", "tool_call", "tool_calls", "toolCalls"
)

if selectors:
evidence["selectors"] = selectors
if descriptions:
evidence["descriptions"] = descriptions
if methods:
evidence["methods"] = methods
if elements:
evidence["elements"] = elements
if extracted_text:
evidence["extracted_text"] = extracted_text
if actions:
evidence["actions"] = actions
if statuses:
evidence["statuses"] = statuses
if attempts:
evidence["attempts"] = attempts
if verifications:
evidence["verification"] = verifications
if method == "observe":
candidates = _observe_candidates(output)
if candidates:
evidence["observe_candidates"] = candidates

return evidence


def _observe_candidates(output: Any) -> list[dict[str, Any]]:
candidates = output if isinstance(output, list) else output.get("candidates", []) if isinstance(output, dict) else []
summary: list[dict[str, Any]] = []
for candidate in candidates:
if not isinstance(candidate, dict):
continue
item = {
key: candidate[key]
for key in ("selector", "description", "method", "action")
if candidate.get(key) is not None
}
if item:
summary.append(item)
return summary


def _find_values(value: Any, *keys: str) -> list[str]:
found: list[str] = []
seen: set[str] = set()
Expand Down
5 changes: 3 additions & 2 deletions docs/stagehand-debugging.html
Original file line number Diff line number Diff line change
Expand Up @@ -273,11 +273,12 @@ <h2>Wrap a Stagehand page</h2>
page = wrap_stagehand(stagehand.page, tracer, name="stagehand checkout run")

await page.goto("https://example.com")
await page.observe("find the checkout button")
await page.act("click the checkout button")
await page.extract("get the order total")
page.bt_run.close()</code></pre>
<p>The wrapper records <code>goto</code>, <code>act</code>, <code>extract</code>, <code>observe</code>, and <code>click</code> calls while preserving the original page methods.</p>
<p>Arguments and keyword arguments are saved as <code>model_input</code>. Successful Stagehand return values are written back to the same trace step as <code>model_output</code>.</p>
<p>Arguments and keyword arguments are saved as <code>model_input</code>. Successful Stagehand return values are written back to the same trace step as <code>model_output</code>, with a compact <code>stagehand_evidence</code> summary for selectors, observe candidates, extracted text, retries, and verification or tool-call metadata.</p>
</section>

<section>
Expand All @@ -286,7 +287,7 @@ <h2>What the trace captures</h2>
<li>The Stagehand method and instruction.</li>
<li>The current page URL.</li>
<li>A screenshot before the action when available.</li>
<li>The successful Stagehand result, including observe or extract output when returned by the wrapped method.</li>
<li>The successful Stagehand result and compact evidence, including observe candidates or extract text when returned by the wrapped method.</li>
<li>Step status and exception text if the action fails.</li>
<li>Exportable HTML with optional model I/O redaction.</li>
</ol>
Expand Down
3 changes: 2 additions & 1 deletion examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,8 @@ BROWSERTRACE_HOME=/tmp/browsertrace-demo browsertrace show <run_id>

Expect `browsertrace list --limit 5` to show a recent
`demo: stagehand checkout flow` run, and `browsertrace show <run_id>` to list
two successful steps: `act: click the checkout button` and
three successful steps: `observe: find the checkout button`,
`act: click the checkout button`, and
`extract: extract the order total`.

For a no-service Browser Use-shaped callback demo, run:
Expand Down
25 changes: 22 additions & 3 deletions examples/stagehand_wrapper_example.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,34 @@ async def screenshot(self) -> bytes:

async def act(self, instruction: str) -> dict[str, str]:
await asyncio.sleep(0)
return {"status": "completed", "instruction": instruction}
return {
"status": "completed",
"instruction": instruction,
"action": "click",
"selector": "button.checkout.primary",
"attempts": 1,
}

async def extract(self, instruction: str) -> dict[str, str]:
await asyncio.sleep(0)
return {
"status": "completed",
"instruction": instruction,
"order_total": "$42.00",
"text": "Order total: $42.00",
"tool_calls": [{"name": "extract_text", "status": "ok"}],
}

async def observe(self, instruction: str) -> list[dict[str, str]]:
await asyncio.sleep(0)
return [
{
"selector": "button.checkout.primary",
"description": "Checkout button",
"method": "click",
"instruction": instruction,
}
]


async def main() -> None:
tracer = Tracer(home=os.environ.get("BROWSERTRACE_HOME"))
Expand All @@ -45,12 +63,13 @@ async def main() -> None:
name="demo: stagehand checkout flow",
)

await page.observe("find the checkout button")
await page.act("click the checkout button")
await page.extract("extract the order total")
page.bt_run.close()

print(f"BrowserTrace run id: {page.bt_run.id}")
print("Recorded Stagehand calls: act, extract")
print("Recorded Stagehand calls: observe, act, extract")
print("Open the local UI with: browsertrace")


Expand Down
8 changes: 6 additions & 2 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ def test_stagehand_wrapper_example_creates_completed_trace(tmp_path, monkeypatch
"SELECT id, name, status, error FROM runs ORDER BY started_at DESC LIMIT 1"
).fetchone()
steps = c.execute(
"SELECT action, url, status, model_input, screenshot_path "
"SELECT action, url, status, model_input, screenshot_path, model_output "
"FROM steps WHERE run_id=? ORDER BY step_index",
(run[0],),
).fetchall()
Expand All @@ -76,13 +76,17 @@ def test_stagehand_wrapper_example_creates_completed_trace(tmp_path, monkeypatch
assert run[2] == "completed"
assert run[3] is None
assert [step[0] for step in steps] == [
"observe: find the checkout button",
"act: click the checkout button",
"extract: extract the order total",
]
assert steps[0][1] == "https://shop.example.test/cart"
assert steps[0][2] == "ok"
assert json.loads(steps[0][3])["method"] == "act"
assert json.loads(steps[0][3])["method"] == "observe"
assert steps[0][4].endswith(".png")
assert json.loads(steps[0][5])["stagehand_evidence"]["selectors"] == [
"button.checkout.primary"
]


def test_browser_use_callback_demo_creates_completed_trace(tmp_path, monkeypatch):
Expand Down
Loading