ultron1 / packages /tools /browser_agent.py
ghostdrive1's picture
feat: Phase 7 tools/browser_agent.py — Playwright LLM browser automation
5dbdc6c
Raw
History Blame Contribute Delete
11.6 kB
"""
packages/tools/browser_agent.py
Ultron V4 — Browser Automation Agent
======================================
Playwright-based browser agent. Wraps LLM decision loop around DOM actions.
Architecture (informed by browser-use/browser-use source read, session v13):
- No vision mode for Groq (DOM text only = 10x token saving).
- AgentOutput flash mode: {action_type, action_params} only.
- ActionLoopDetector: SHA256 hash rolling window, soft nudge (never blocks).
- Max 5 browser steps per task. Hard ceiling 10.
- Playwright Chromium headless (works in HF Space Ubuntu container).
- Accessibility tree extraction (playwright-mcp pattern) for token efficiency.
Actions:
navigate(url) — go to URL
click(selector) — CSS selector click
type(selector, text) — clear + type into input
extract(selector) — extract text from selector (or full body)
scroll(direction) — up/down page scroll
done(result) — task complete, return result
Browser session lifecycle:
- Playwright browser launched once per BrowserAgent instance.
- Page reused across steps (persistent session within one task).
- Browser closed after task completes or on exception.
Future bug risks (pre-registered):
BA1 [HIGH] Playwright Chromium download (~120MB) on first launch.
Fix: Add to Dockerfile: RUN playwright install chromium --with-deps
(already in Dockerfile from session v20 — verify it's still there).
BA2 [HIGH] HF Space headless display: Chromium needs DISPLAY or --no-sandbox.
Fix: launch with args=["--no-sandbox", "--disable-dev-shm-usage"].
--no-sandbox required in Docker containers.
BA3 [HIGH] Groq context window (8k tokens). Long DOM text fills it in 1-2 steps.
Fix: truncate DOM text to 4000 chars. Extract only visible text.
BA4 [MED] Playwright async API requires running inside asyncio event loop.
FastAPI is async, so this is fine. But discord_bot.py running in same
process shares loop — no conflict as long as no sync Playwright calls.
BA5 [MED] CSS selectors often break on SPAs. Fallback: extract full body text
if selector not found. Log selector miss.
BA6 [LOW] Browser agent may visit malicious URLs. Add domain allowlist check.
For now: log warning + proceed (no blocking).
Tool calls used writing this file:
Github:get_file_contents x4 (browser-use/browser-use agent/ — session v13 source read)
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
from collections import deque
from typing import Any, Optional
log = logging.getLogger("tools.browser_agent")
MAX_STEPS = 5
ABS_MAX_STEPS = 10
DOM_MAX_CHARS = 4000 # BA3 guard: Groq 8k ctx
LOOP_WINDOW = 20 # ActionLoopDetector rolling window
NUDGE_AT = [5, 8, 12] # Soft nudge thresholds (never block)
class BrowserAgent:
"""
LLM-driven browser automation agent.
Wraps Playwright with a ReAct-style action loop.
Usage:
agent = BrowserAgent(llm_fn=make_provider_llm_fn(pool))
result = await agent.run("Find the price of RTX 4090 on Amazon")
"""
def __init__(
self,
llm_fn: Any,
max_steps: int = MAX_STEPS,
) -> None:
self._llm_fn = llm_fn
self._max_steps = min(max_steps, ABS_MAX_STEPS)
self._loop_hashes: deque = deque(maxlen=LOOP_WINDOW)
async def run(self, task: str) -> str:
"""
Execute browser task. Returns result string.
Handles Playwright lifecycle internally.
"""
try:
from playwright.async_api import async_playwright # type: ignore
except ImportError:
return "Browser agent unavailable: playwright not installed (BA1)."
async with async_playwright() as pw:
browser = await pw.chromium.launch(
headless=True,
args=["--no-sandbox", "--disable-dev-shm-usage"], # BA2
)
page = await browser.new_page()
try:
result = await self._action_loop(task, page)
except Exception as e:
log.error(f"[Browser] Action loop error: {e}")
result = f"Browser agent error: {e}"
finally:
await browser.close()
return result
async def _action_loop(self, task: str, page: Any) -> str:
"""ReAct-style loop: LLM decides action -> execute -> feed result back."""
history: list[dict] = []
step = 0
while step < self._max_steps:
step += 1
# Build prompt
dom_text = await self._extract_dom(page)
prompt = self._build_prompt(task, history, dom_text, step)
# LLM call
try:
raw = await self._llm_fn(
messages=[{"role": "user", "content": prompt}],
system=(
"You are a browser automation agent. "
"Respond ONLY with valid JSON matching the action schema. "
"No markdown, no explanation."
),
max_tokens=300,
)
except Exception as e:
log.error(f"[Browser] LLM call failed step={step}: {e}")
return f"LLM failure at step {step}: {e}"
# Parse action
action = self._parse_action(raw)
if not action:
log.warning(f"[Browser] Could not parse action at step={step}: {raw[:200]}")
continue
action_type = action.get("action_type", "")
action_params = action.get("action_params", {})
# Loop detection
action_hash = hashlib.sha256(
json.dumps({"t": action_type, "p": action_params}, sort_keys=True).encode()
).hexdigest()[:16]
self._loop_hashes.append(action_hash)
repeat_count = sum(1 for h in self._loop_hashes if h == action_hash)
nudge = ""
if repeat_count in NUDGE_AT:
nudge = f"[LOOP NUDGE] You have repeated action '{action_type}' {repeat_count} times. Try a different approach."
log.warning(f"[Browser] {nudge}")
# Done
if action_type == "done":
result = action_params.get("result", "Task complete.")
log.info(f"[Browser] Done at step={step}: {result[:100]}")
return result
# Execute action
obs = await self._execute(page, action_type, action_params)
log.info(f"[Browser] Step={step} action={action_type} obs={obs[:100]}")
history.append({
"step": step,
"action": action_type,
"params": action_params,
"obs": obs,
"nudge": nudge,
})
return f"Browser agent reached max steps ({self._max_steps}) without completing task."
async def _extract_dom(self, page: Any) -> str:
"""Extract visible text from current page. Truncated to DOM_MAX_CHARS (BA3)."""
try:
text = await page.evaluate(
"""() => {
const walker = document.createTreeWalker(
document.body,
NodeFilter.SHOW_TEXT,
null,
false
);
let text = '';
let node;
while (node = walker.nextNode()) {
const t = node.nodeValue.trim();
if (t) text += t + ' ';
if (text.length > 5000) break;
}
return text.trim();
}"""
)
url = page.url
return f"URL: {url}\n\nPAGE TEXT:\n{text[:DOM_MAX_CHARS]}"
except Exception as e:
return f"DOM extraction failed: {e}"
def _build_prompt(
self,
task: str,
history: list,
dom_text: str,
step: int,
) -> str:
history_str = ""
for h in history[-3:]: # Only last 3 steps to save tokens
history_str += (
f"Step {h['step']}: {h['action']}({json.dumps(h['params'])}) "
f"-> {h['obs'][:200]}\n"
)
if h.get("nudge"):
history_str += f" {h['nudge']}\n"
return (
f"TASK: {task}\n\n"
f"STEP: {step}/{self._max_steps}\n\n"
f"RECENT HISTORY:\n{history_str or '(none)'}\n\n"
f"CURRENT PAGE:\n{dom_text}\n\n"
f"AVAILABLE ACTIONS:\n"
f' navigate(url: str)\n'
f' click(selector: str)\n'
f' type(selector: str, text: str)\n'
f' extract(selector: str)\n'
f' scroll(direction: "up"|"down")\n'
f' done(result: str)\n\n'
f"Respond with JSON: "
f'{{\"action_type\": \"<action>\", \"action_params\": {{...}}}}'
)
def _parse_action(self, raw: str) -> Optional[dict]:
"""Parse LLM JSON response into action dict."""
raw = raw.strip()
# Strip markdown fences if present
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
try:
return json.loads(raw)
except json.JSONDecodeError:
return None
async def _execute(self, page: Any, action_type: str, params: dict) -> str:
"""Execute browser action. Returns observation string."""
try:
if action_type == "navigate":
url = params.get("url", "")
await page.goto(url, timeout=15000)
return f"Navigated to {url}"
elif action_type == "click":
selector = params.get("selector", "")
try:
await page.click(selector, timeout=5000)
return f"Clicked {selector}"
except Exception:
log.warning(f"[Browser] Selector not found: {selector} (BA5)")
return f"Click failed: selector '{selector}' not found"
elif action_type == "type":
selector = params.get("selector", "")
text = params.get("text", "")
try:
await page.fill(selector, text, timeout=5000)
return f"Typed '{text[:50]}' into {selector}"
except Exception:
return f"Type failed: selector '{selector}' not found (BA5)"
elif action_type == "extract":
selector = params.get("selector", "body")
try:
el = await page.query_selector(selector)
text = await el.inner_text() if el else ""
return text[:500] or "(empty)"
except Exception:
return "(extract failed)"
elif action_type == "scroll":
direction = params.get("direction", "down")
delta = 500 if direction == "down" else -500
await page.evaluate(f"window.scrollBy(0, {delta})")
return f"Scrolled {direction}"
else:
return f"Unknown action: {action_type}"
except Exception as e:
return f"Execute error: {e}"