feat: extension browser tools

This commit is contained in:
Timothy
2026-04-02 15:58:52 -07:00
parent c7e85aa9f5
commit 71a71beca7
18 changed files with 1449 additions and 241 deletions
+7 -2
View File
@@ -80,8 +80,13 @@ class LoopConfig:
# Per-tool-call timeout. # Per-tool-call timeout.
tool_call_timeout_seconds: float = 60.0 tool_call_timeout_seconds: float = 60.0
# Subagent delegation timeout. # Subagent delegation timeout (wall-clock max).
subagent_timeout_seconds: float = 600.0 subagent_timeout_seconds: float = 3600.0
# Subagent inactivity timeout - only timeout if no activity for this duration.
# This resets whenever the subagent makes progress (tool calls, LLM responses).
# Set to 0 to use only the wall-clock timeout.
subagent_inactivity_timeout_seconds: float = 300.0
# Lifecycle hooks. # Lifecycle hooks.
hooks: dict[str, list] | None = None hooks: dict[str, list] | None = None
+98 -16
View File
@@ -423,6 +423,11 @@ class EventLoopNode(NodeProtocol):
_restored_recent_responses = [] _restored_recent_responses = []
_restored_tool_fingerprints = [] _restored_tool_fingerprints = []
# Clear any stale conversation parts before starting fresh.
# This ensures a clean slate even if the store directory is reused.
if self._conversation_store is not None:
await self._conversation_store.clear()
# Fresh conversation: either isolated mode or first node in continuous mode. # Fresh conversation: either isolated mode or first node in continuous mode.
from framework.graph.prompt_composer import ( from framework.graph.prompt_composer import (
EXECUTION_SCOPE_PREAMBLE, EXECUTION_SCOPE_PREAMBLE,
@@ -2586,40 +2591,117 @@ class EventLoopNode(NodeProtocol):
# Phase 2b: execute subagent delegations in parallel. # Phase 2b: execute subagent delegations in parallel.
if pending_subagent: if pending_subagent:
_subagent_timeout = self._config.subagent_timeout_seconds _subagent_timeout = self._config.subagent_timeout_seconds
_inactivity_timeout = self._config.subagent_inactivity_timeout_seconds
async def _timed_subagent( async def _timed_subagent(
_ctx: NodeContext, _ctx: NodeContext,
_tc: ToolCallEvent, _tc: ToolCallEvent,
_acc: OutputAccumulator = accumulator, _acc: OutputAccumulator = accumulator,
_timeout: float = _subagent_timeout, _wall_timeout: float = _subagent_timeout,
_activity_timeout: float = _inactivity_timeout,
) -> tuple[ToolResult | BaseException, str, float]: ) -> tuple[ToolResult | BaseException, str, float]:
_s = time.time() _s = time.time()
_iso = datetime.now(UTC).isoformat() _iso = datetime.now(UTC).isoformat()
_last_activity = _s
_activity_event = asyncio.Event()
async def _watchdog() -> None:
"""Watchdog that times out only after inactivity period."""
nonlocal _last_activity
while True:
_now = time.time()
_inactive_for = _now - _last_activity
_remaining = _activity_timeout - _inactive_for
if _remaining <= 0:
# Inactivity timeout reached
return
try:
await asyncio.wait_for(
_activity_event.wait(),
timeout=_remaining
)
_activity_event.clear()
except TimeoutError:
# Check again in case activity happened during wait
continue
async def _run_with_activity_timeout(
_coro,
) -> ToolResult:
"""Run subagent with activity-based timeout."""
_watchdog_task = asyncio.create_task(_watchdog())
try:
_result = await _coro
return _result
finally:
_watchdog_task.cancel()
try:
await _watchdog_task
except asyncio.CancelledError:
pass
try: try:
_coro = self._execute_subagent( # Subscribe to subagent activity events to reset inactivity timer
_ctx, async def _on_subagent_activity(event) -> None:
_tc.tool_input.get("agent_id", ""), nonlocal _last_activity
_tc.tool_input.get("task", ""), _last_activity = time.time()
accumulator=_acc, _activity_event.set()
)
if _timeout > 0: _sub_id = None
_r = await asyncio.wait_for(_coro, timeout=_timeout) if self._event_bus and _activity_timeout > 0:
else: from framework.runtime.event_bus import EventType
_r = await _coro _sub_id = self._event_bus.subscribe(
event_types=[
EventType.TOOL_CALL_STARTED,
EventType.LLM_TEXT_DELTA,
EventType.EXECUTION_STARTED,
],
handler=_on_subagent_activity,
)
try:
_coro = self._execute_subagent(
_ctx,
_tc.tool_input.get("agent_id", ""),
_tc.tool_input.get("task", ""),
accumulator=_acc,
)
if _activity_timeout > 0:
# Use activity-based timeout with wall-clock max
_result_coro = _run_with_activity_timeout(_coro)
if _wall_timeout > 0:
_r = await asyncio.wait_for(
_result_coro, timeout=_wall_timeout
)
else:
_r = await _result_coro
elif _wall_timeout > 0:
_r = await asyncio.wait_for(_coro, timeout=_wall_timeout)
else:
_r = await _coro
finally:
if _sub_id and self._event_bus:
self._event_bus.unsubscribe(_sub_id)
except TimeoutError: except TimeoutError:
_agent_id = _tc.tool_input.get("agent_id", "unknown") _agent_id = _tc.tool_input.get("agent_id", "unknown")
_elapsed = time.time() - _s
logger.warning( logger.warning(
"Subagent '%s' timed out after %.0fs", "Subagent '%s' timed out after %.0fs (inactivity threshold: %.0fs)",
_agent_id, _agent_id,
_timeout, _elapsed,
_activity_timeout if _activity_timeout > 0 else _wall_timeout,
) )
_r = ToolResult( _r = ToolResult(
tool_use_id=_tc.tool_use_id, tool_use_id=_tc.tool_use_id,
content=( content=(
f"Subagent '{_agent_id}' timed out after " f"Subagent '{_agent_id}' timed out after "
f"{_timeout:.0f}s. The delegation took " f"{_elapsed:.0f}s of inactivity. "
"too long and was cancelled. Try a simpler task " "The subagent was not making progress. "
"or break it into smaller pieces." "Try a simpler task or break it into smaller pieces."
), ),
is_error=True, is_error=True,
) )
@@ -110,6 +110,28 @@ class FileConversationStore:
"""No-op — no persistent handles for file-per-part storage.""" """No-op — no persistent handles for file-per-part storage."""
pass pass
async def clear(self) -> None:
"""Clear all parts and cursor, keeping the directory structure.
Used when starting a fresh execution in the same session directory.
"""
def _clear() -> None:
# Clear all parts
if self._parts_dir.exists():
for f in self._parts_dir.glob("*.json"):
f.unlink()
# Clear cursor
cursor_path = self._base / "cursor.json"
if cursor_path.exists():
cursor_path.unlink()
# Clear meta
meta_path = self._base / "meta.json"
if meta_path.exists():
meta_path.unlink()
await self._run(_clear)
async def destroy(self) -> None: async def destroy(self) -> None:
"""Delete the entire base directory and all persisted data.""" """Delete the entire base directory and all persisted data."""
+44 -1
View File
@@ -257,12 +257,55 @@ fi
# Check for Chrome/Edge (required for GCU browser tools) # Check for Chrome/Edge (required for GCU browser tools)
echo -n " Checking for Chrome/Edge browser... " echo -n " Checking for Chrome/Edge browser... "
if uv run python -c "from gcu.browser.chrome_finder import find_chrome; assert find_chrome()" > /dev/null 2>&1; then # Check common browser locations
CHROME_FOUND=false
for browser in "google-chrome" "google-chrome-stable" "chromium" "chromium-browser" "microsoft-edge"; do
if command -v "$browser" &> /dev/null; then
CHROME_FOUND=true
break
fi
done
# Also check common desktop locations (for macOS/Windows)
if [ "$CHROME_FOUND" = false ]; then
for path in "/Applications/Google Chrome.app" "/mnt/c/Program Files/Google/Chrome/Application/chrome.exe" "/mnt/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" "$HOME/Applications/Google Chrome.app" "$HOME/.local/share/applications/google-chrome.desktop"; do
if [ -e "$path" ]; then
CHROME_FOUND=true
break
fi
done
fi
if [ "$CHROME_FOUND" = true ]; then
echo -e "${GREEN}ok${NC}" echo -e "${GREEN}ok${NC}"
else else
echo -e "${YELLOW}not found — install Chrome or Edge for browser tools${NC}" echo -e "${YELLOW}not found — install Chrome or Edge for browser tools${NC}"
fi fi
# Ensure playwright is installed for web scraping tools
echo -n " Checking playwright installation... "
if uv run python -c "import playwright" > /dev/null 2>&1; then
# Check if browser binaries are installed
if uv run playwright install --dry-run chromium > /dev/null 2>&1; then
echo -e "${GREEN}ok${NC}"
else
echo -e "${YELLOW}installing browser...${NC}"
uv run playwright install chromium > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo -e " ${GREEN}✓ playwright chromium installed${NC}"
else
echo -e " ${YELLOW}⚠ playwright browser installation failed (web scraping may not work)${NC}"
fi
fi
else
echo -e "${YELLOW}not found — installing...${NC}"
uv pip install playwright playwright-stealth > /dev/null 2>&1
uv run playwright install chromium > /dev/null 2>&1
if [ $? -eq 0 ]; then
echo -e " ${GREEN}✓ playwright installed${NC}"
else
echo -e " ${YELLOW}⚠ playwright installation failed (web scraping may not work)${NC}"
fi
fi
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
echo "" echo ""
echo -e "${GREEN}${NC} All packages installed" echo -e "${GREEN}${NC} All packages installed"
+20 -4
View File
@@ -134,13 +134,29 @@ async function dispatch(type, params) {
// ── Debugger (CDP) ──────────────────────────────────────────────────── // ── Debugger (CDP) ────────────────────────────────────────────────────
case "cdp.attach": { case "cdp.attach": {
await chrome.debugger.attach({ tabId: params.tabId }, "1.3"); try {
return { ok: true }; await chrome.debugger.attach({ tabId: params.tabId }, "1.3");
return { ok: true, attached: true };
} catch (err) {
// Already attached is OK
if (err.message.includes("already attached") || err.message.includes("Debugger")) {
return { ok: true, attached: false, message: "Already attached" };
}
throw err;
}
} }
case "cdp.detach": { case "cdp.detach": {
await chrome.debugger.detach({ tabId: params.tabId }); try {
return { ok: true }; await chrome.debugger.detach({ tabId: params.tabId });
return { ok: true };
} catch (err) {
// Not attached is OK
if (err.message.includes("not attached") || err.message.includes("Debugger")) {
return { ok: true, message: "Was not attached" };
}
throw err;
}
} }
case "cdp": { case "cdp": {
+41 -16
View File
@@ -9,32 +9,57 @@
const HIVE_WS_URL = "ws://127.0.0.1:9229/bridge"; const HIVE_WS_URL = "ws://127.0.0.1:9229/bridge";
let ws = null; let ws = null;
let reconnectAttempts = 0;
function connect() { function connect() {
ws = new WebSocket(HIVE_WS_URL); // Don't try to reconnect too fast
const delay = Math.min(reconnectAttempts * 1000, 5000);
ws.onopen = () => { if (reconnectAttempts > 0) {
chrome.runtime.sendMessage({ _beeline: true, type: "ws_open" }); console.log(`[Beeline] Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1})...`);
}; }
ws.onmessage = (event) => { setTimeout(() => {
chrome.runtime.sendMessage({ _beeline: true, type: "ws_message", data: event.data }); try {
}; ws = new WebSocket(HIVE_WS_URL);
ws.onclose = () => { ws.onopen = () => {
chrome.runtime.sendMessage({ _beeline: true, type: "ws_close" }); console.log("[Beeline] WebSocket connected to Hive");
setTimeout(connect, 2000); reconnectAttempts = 0;
}; chrome.runtime.sendMessage({ _beeline: true, type: "ws_open" });
};
ws.onerror = () => { ws.onmessage = (event) => {
ws.close(); chrome.runtime.sendMessage({ _beeline: true, type: "ws_message", data: event.data });
}; };
ws.onclose = (event) => {
console.log(`[Beeline] WebSocket closed: code=${event.code}`);
chrome.runtime.sendMessage({ _beeline: true, type: "ws_close" });
reconnectAttempts++;
setTimeout(connect, 2000);
};
ws.onerror = (error) => {
console.error("[Beeline] WebSocket error:", error);
ws.close();
};
} catch (error) {
console.error("[Beeline] Failed to create WebSocket:", error);
reconnectAttempts++;
setTimeout(connect, 2000);
}
}, delay);
} }
// Forward outbound messages from the service worker onto the WebSocket. // Forward outbound messages from the service worker onto the WebSocket.
chrome.runtime.onMessage.addListener((msg) => { chrome.runtime.onMessage.addListener((msg) => {
if (msg._beeline && msg.type === "ws_send" && ws?.readyState === WebSocket.OPEN) { if (msg._beeline && msg.type === "ws_send") {
ws.send(msg.data); if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(msg.data);
} else {
console.warn("[Beeline] Cannot send - WebSocket not connected");
}
} }
}); });
+4
View File
@@ -49,6 +49,8 @@ sandbox = [
] ]
browser = [ browser = [
"pillow>=10.0.0", "pillow>=10.0.0",
"playwright>=1.40.0",
"playwright-stealth>=2.0.0",
] ]
ocr = [ ocr = [
"pytesseract>=0.3.10", "pytesseract>=0.3.10",
@@ -76,6 +78,8 @@ all = [
"google-cloud-bigquery>=3.0.0", "google-cloud-bigquery>=3.0.0",
"databricks-sdk>=0.30.0", "databricks-sdk>=0.30.0",
"databricks-mcp>=0.1.0", "databricks-mcp>=0.1.0",
"playwright>=1.40.0",
"playwright-stealth>=2.0.0",
] ]
[tool.uv.sources] [tool.uv.sources]
+7 -2
View File
@@ -133,7 +133,11 @@ from .twilio_tool import register_tools as register_twilio
from .twitter_tool import register_tools as register_twitter from .twitter_tool import register_tools as register_twitter
from .vercel_tool import register_tools as register_vercel from .vercel_tool import register_tools as register_vercel
from .vision_tool import register_tools as register_vision from .vision_tool import register_tools as register_vision
from .web_scrape_tool import register_tools as register_web_scrape try:
from .web_scrape_tool import register_tools as register_web_scrape
except ImportError:
# playwright not installed - web_scrape_tool unavailable
register_web_scrape = None # type: ignore
from .web_search_tool import register_tools as register_web_search from .web_search_tool import register_tools as register_web_search
from .wikipedia_tool import register_tools as register_wikipedia from .wikipedia_tool import register_tools as register_wikipedia
from .yahoo_finance_tool import register_tools as register_yahoo_finance from .yahoo_finance_tool import register_tools as register_yahoo_finance
@@ -151,7 +155,8 @@ def _register_verified(
"""Register verified (stable) tools.""" """Register verified (stable) tools."""
# --- No credentials --- # --- No credentials ---
register_example(mcp) register_example(mcp)
register_web_scrape(mcp) if register_web_scrape:
register_web_scrape(mcp)
register_pdf_read(mcp) register_pdf_read(mcp)
register_time(mcp) register_time(mcp)
register_runtime_logs(mcp) register_runtime_logs(mcp)
+78 -15
View File
@@ -24,8 +24,16 @@ from __future__ import annotations
import asyncio import asyncio
import json import json
import logging import logging
import time
from typing import Any from typing import Any
from .telemetry import (
log_bridge_message,
log_cdp_command,
log_connection_event,
log_context_event,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
BRIDGE_PORT = 9229 BRIDGE_PORT = 9229
@@ -34,6 +42,16 @@ BRIDGE_PORT = 9229
VALID_WAIT_UNTIL = {"commit", "domcontentloaded", "load", "networkidle"} VALID_WAIT_UNTIL = {"commit", "domcontentloaded", "load", "networkidle"}
def _get_active_profile() -> str:
"""Get the current active profile from context variable."""
try:
from .session import _active_profile as ap
return ap.get()
except Exception:
return "default"
class BeelineBridge: class BeelineBridge:
"""WebSocket server that accepts a single connection from the Chrome extension.""" """WebSocket server that accepts a single connection from the Chrome extension."""
@@ -42,6 +60,7 @@ class BeelineBridge:
self._server: object | None = None # websockets.Server self._server: object | None = None # websockets.Server
self._pending: dict[str, asyncio.Future] = {} self._pending: dict[str, asyncio.Future] = {}
self._counter = 0 self._counter = 0
self._cdp_attached: set[int] = set() # Track tabs with CDP attached
@property @property
def is_connected(self) -> bool: def is_connected(self) -> bool:
@@ -75,6 +94,7 @@ class BeelineBridge:
async def _handle_connection(self, ws) -> None: async def _handle_connection(self, ws) -> None:
logger.info("Chrome extension connected") logger.info("Chrome extension connected")
log_connection_event("connect")
self._ws = ws self._ws = ws
try: try:
async for raw in ws: async for raw in ws:
@@ -85,6 +105,7 @@ class BeelineBridge:
if msg.get("type") == "hello": if msg.get("type") == "hello":
logger.info("Extension hello: version=%s", msg.get("version")) logger.info("Extension hello: version=%s", msg.get("version"))
log_connection_event("hello", {"version": msg.get("version")})
continue continue
msg_id = msg.get("id") msg_id = msg.get("id")
@@ -92,13 +113,20 @@ class BeelineBridge:
fut = self._pending.pop(msg_id) fut = self._pending.pop(msg_id)
if not fut.done(): if not fut.done():
if "error" in msg: if "error" in msg:
log_bridge_message(
"recv", "response", msg_id=msg_id, error=msg["error"]
)
fut.set_exception(RuntimeError(msg["error"])) fut.set_exception(RuntimeError(msg["error"]))
else: else:
log_bridge_message(
"recv", "response", msg_id=msg_id, result=msg.get("result")
)
fut.set_result(msg.get("result", {})) fut.set_result(msg.get("result", {}))
except Exception: except Exception:
pass pass
finally: finally:
logger.info("Chrome extension disconnected") logger.info("Chrome extension disconnected")
log_connection_event("disconnect")
self._ws = None self._ws = None
# Cancel any pending requests # Cancel any pending requests
for fut in self._pending.values(): for fut in self._pending.values():
@@ -114,16 +142,33 @@ class BeelineBridge:
msg_id = str(self._counter) msg_id = str(self._counter)
fut: asyncio.Future = asyncio.get_event_loop().create_future() fut: asyncio.Future = asyncio.get_event_loop().create_future()
self._pending[msg_id] = fut self._pending[msg_id] = fut
start = time.perf_counter()
log_bridge_message("send", type_, msg_id=msg_id, params=params)
try: try:
await self._ws.send(json.dumps({"id": msg_id, "type": type_, **params})) await self._ws.send(json.dumps({"id": msg_id, "type": type_, **params}))
return await asyncio.wait_for(fut, timeout=30.0) result = await asyncio.wait_for(fut, timeout=30.0)
duration_ms = (time.perf_counter() - start) * 1000
log_bridge_message("send", type_, msg_id=msg_id, result=result, duration_ms=duration_ms)
return result
except TimeoutError: except TimeoutError:
self._pending.pop(msg_id, None) self._pending.pop(msg_id, None)
log_bridge_message("send", type_, msg_id=msg_id, error="timeout")
raise RuntimeError(f"Bridge command '{type_}' timed out") from None raise RuntimeError(f"Bridge command '{type_}' timed out") from None
async def _cdp(self, tab_id: int, method: str, params: dict | None = None) -> dict: async def _cdp(self, tab_id: int, method: str, params: dict | None = None) -> dict:
"""Send a CDP command to a tab.""" """Send a CDP command to a tab."""
return await self._send("cdp", tabId=tab_id, method=method, params=params or {}) start = time.perf_counter()
try:
result = await self._send("cdp", tabId=tab_id, method=method, params=params or {})
duration_ms = (time.perf_counter() - start) * 1000
log_cdp_command(tab_id, method, params, result, duration_ms=duration_ms)
return result
except Exception as e:
duration_ms = (time.perf_counter() - start) * 1000
log_cdp_command(tab_id, method, params, error=str(e), duration_ms=duration_ms)
raise
# ── Context (Tab Group) Management ───────────────────────────────────────── # ── Context (Tab Group) Management ─────────────────────────────────────────
@@ -132,11 +177,17 @@ class BeelineBridge:
Returns {"groupId": int, "tabId": int}. Returns {"groupId": int, "tabId": int}.
""" """
return await self._send("context.create", agentId=agent_id) result = await self._send("context.create", agentId=agent_id)
log_context_event(
"create", agent_id, group_id=result.get("groupId"), tab_id=result.get("tabId")
)
return result
async def destroy_context(self, group_id: int) -> dict: async def destroy_context(self, group_id: int) -> dict:
"""Close all tabs in the group and remove it.""" """Close all tabs in the group and remove it."""
return await self._send("context.destroy", groupId=group_id) result = await self._send("context.destroy", groupId=group_id)
log_context_event("destroy", _get_active_profile(), group_id=group_id, details=result)
return result
# ── Tab Management ───────────────────────────────────────────────────────── # ── Tab Management ─────────────────────────────────────────────────────────
@@ -173,11 +224,18 @@ class BeelineBridge:
Returns {"ok": bool}. Returns {"ok": bool}.
""" """
return await self._send("cdp.attach", tabId=tab_id) if tab_id in self._cdp_attached:
return {"ok": True, "attached": False, "message": "Already attached"}
result = await self._send("cdp.attach", tabId=tab_id)
if result.get("ok"):
self._cdp_attached.add(tab_id)
return result
async def cdp_detach(self, tab_id: int) -> dict: async def cdp_detach(self, tab_id: int) -> dict:
"""Detach CDP debugger from a tab.""" """Detach CDP debugger from a tab."""
return await self._send("cdp.detach", tabId=tab_id) result = await self._send("cdp.detach", tabId=tab_id)
self._cdp_attached.discard(tab_id)
return result
# ── Navigation ───────────────────────────────────────────────────────────── # ── Navigation ─────────────────────────────────────────────────────────────
@@ -562,9 +620,7 @@ class BeelineBridge:
return {"ok": True, "action": "hover", "selector": selector} return {"ok": True, "action": "hover", "selector": selector}
async def scroll( async def scroll(self, tab_id: int, direction: str = "down", amount: int = 500) -> dict:
self, tab_id: int, direction: str = "down", amount: int = 500
) -> dict:
"""Scroll the page.""" """Scroll the page."""
await self.cdp_attach(tab_id) await self.cdp_attach(tab_id)
@@ -619,10 +675,12 @@ class BeelineBridge:
async def evaluate(self, tab_id: int, script: str) -> dict: async def evaluate(self, tab_id: int, script: str) -> dict:
"""Execute JavaScript in the page.""" """Execute JavaScript in the page."""
await self.cdp_attach(tab_id) await self.cdp_attach(tab_id)
# Wrap in IIFE to allow return statements at top level
wrapped_script = f"(function() {{ {script} }})()"
result = await self._cdp( result = await self._cdp(
tab_id, tab_id,
"Runtime.evaluate", "Runtime.evaluate",
{"expression": script, "returnByValue": True, "awaitPromise": True}, {"expression": wrapped_script, "returnByValue": True, "awaitPromise": True},
) )
if "exceptionDetails" in result: if "exceptionDetails" in result:
@@ -722,8 +780,15 @@ class BeelineBridge:
# Add ref for interactive elements # Add ref for interactive elements
interactive_roles = { interactive_roles = {
"button", "link", "textbox", "checkbox", "button",
"radio", "combobox", "menuitem", "tab", "searchbox", "link",
"textbox",
"checkbox",
"radio",
"combobox",
"menuitem",
"tab",
"searchbox",
} }
if role in interactive_roles or name: if role in interactive_roles or name:
ref_counter[0] += 1 ref_counter[0] += 1
@@ -841,9 +906,7 @@ class BeelineBridge:
"mimeType": "image/png", "mimeType": "image/png",
} }
async def wait_for_selector( async def wait_for_selector(self, tab_id: int, selector: str, timeout_ms: int = 30000) -> dict:
self, tab_id: int, selector: str, timeout_ms: int = 30000
) -> dict:
"""Wait for an element to appear.""" """Wait for an element to appear."""
await self.cdp_attach(tab_id) await self.cdp_attach(tab_id)
+29 -25
View File
@@ -14,32 +14,36 @@ if TYPE_CHECKING:
from .session import BrowserSession from .session import BrowserSession
# Role sets for interactive elements # Role sets for interactive elements
INTERACTIVE_ROLES: frozenset[str] = frozenset({ INTERACTIVE_ROLES: frozenset[str] = frozenset(
"button", {
"checkbox", "button",
"combobox", "checkbox",
"link", "combobox",
"listbox", "link",
"menuitem", "listbox",
"menuitemcheckbox", "menuitem",
"menuitemradio", "menuitemcheckbox",
"option", "menuitemradio",
"radio", "option",
"scrollbar", "radio",
"searchbox", "scrollbar",
"slider", "searchbox",
"spinbutton", "slider",
"switch", "spinbutton",
"tab", "switch",
"textbox", "tab",
"treeitem", "textbox",
}) "treeitem",
}
)
NAMED_CONTENT_ROLES: frozenset[str] = frozenset({ NAMED_CONTENT_ROLES: frozenset[str] = frozenset(
"cell", {
"heading", "cell",
"img", "heading",
}) "img",
}
)
# Regex for parsing aria snapshot lines # Regex for parsing aria snapshot lines
_LINE_RE = re.compile(r"^(\s*-\s+)(\w+)(?:\s+\"([^\"]*)\")?(.*?)$") _LINE_RE = re.compile(r"^(\s*-\s+)(\w+)(?:\s+\"([^\"]*)\")?(.*?)$")
+286
View File
@@ -0,0 +1,286 @@
"""
Browser telemetry logging - rich context for debugging browser tools.
Logs are written to .hive/browser-logs/ as JSON lines for easy parsing.
Each log entry includes:
- Timestamp and unique trace ID
- Agent/profile context
- Tool/operation name and parameters
- Timing information
- Results or errors with full context
"""
from __future__ import annotations
import functools
import json
import os
import time
import traceback
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, TypeVar
# Try to import context variable, but don't fail if not available
try:
from .session import _active_profile
except ImportError:
_active_profile = None
# Type variables for decorator
F = TypeVar("F", bound=Callable[..., Any])
# Log directory setup
LOG_DIR = Path(__file__).parent.parent.parent.parent.parent / ".hive" / "browser-logs"
LOG_DIR.mkdir(parents=True, exist_ok=True)
# Current log file (rotated daily)
_current_log_file: Path | None = None
_log_file_date: str | None = None
def _get_log_file() -> Path:
"""Get the current log file, rotating daily."""
global _current_log_file, _log_file_date
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
if _log_file_date != today:
_log_file_date = today
_current_log_file = LOG_DIR / f"browser-{today}.jsonl"
return _current_log_file
def _get_profile() -> str:
"""Get the current profile from context variable."""
if _active_profile is not None:
try:
return _active_profile.get()
except Exception:
pass
return "default"
def _truncate(value: Any, max_len: int = 500) -> Any:
"""Truncate long strings for readability."""
if isinstance(value, str) and len(value) > max_len:
return value[:max_len] + f"... (+{len(value) - max_len} chars)"
if isinstance(value, bytes):
return f"<bytes:{len(value)}>"
if isinstance(value, dict):
return {k: _truncate(v, max_len) for k, v in value.items()}
if isinstance(value, list):
if len(value) > 10:
return [_truncate(v, max_len) for v in value[:10]] + [f"... (+{len(value) - 10} items)"]
return [_truncate(v, max_len) for v in value]
return value
def _sanitize_params(params: dict[str, Any]) -> dict[str, Any]:
"""Sanitize parameters for logging - remove sensitive data, truncate."""
sanitized = {}
sensitive_keys = {"password", "token", "secret", "credential", "api_key", "apikey"}
for key, value in params.items():
key_lower = key.lower()
if any(s in key_lower for s in sensitive_keys):
sanitized[key] = "***REDACTED***"
else:
sanitized[key] = _truncate(value)
return sanitized
def write_log(entry: dict[str, Any]) -> None:
"""Write a log entry to the current log file."""
try:
log_file = _get_log_file()
entry["ts"] = datetime.now(timezone.utc).isoformat()
entry["profile"] = _get_profile()
with open(log_file, "a", encoding="utf-8") as f:
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception:
# Don't let logging errors break operations
pass
def log_tool_call(
tool_name: str,
params: dict[str, Any],
result: dict[str, Any] | None = None,
error: Exception | None = None,
duration_ms: float | None = None,
) -> None:
"""Log a tool invocation."""
entry: dict[str, Any] = {
"type": "tool_call",
"tool": tool_name,
"params": _sanitize_params(params),
}
if result is not None:
entry["result"] = _truncate(result, max_len=1000)
entry["ok"] = result.get("ok", True)
if error is not None:
entry["error"] = str(error)
entry["error_type"] = type(error).__name__
entry["traceback"] = traceback.format_exc()
if duration_ms is not None:
entry["duration_ms"] = round(duration_ms, 2)
write_log(entry)
def log_bridge_message(
direction: str, # "send" or "recv"
msg_type: str,
msg_id: str | int | None = None,
params: dict[str, Any] | None = None,
result: dict[str, Any] | None = None,
error: str | None = None,
duration_ms: float | None = None,
) -> None:
"""Log a bridge WebSocket message."""
entry: dict[str, Any] = {
"type": "bridge",
"direction": direction,
"msg_type": msg_type,
}
if msg_id is not None:
entry["msg_id"] = msg_id
if params:
entry["params"] = _sanitize_params(params)
if result:
entry["result"] = _truncate(result, max_len=1000)
if error:
entry["error"] = error
if duration_ms is not None:
entry["duration_ms"] = round(duration_ms, 2)
write_log(entry)
def log_context_event(
event: str, # "create", "destroy", "start", "stop"
profile: str,
group_id: int | None = None,
tab_id: int | None = None,
details: dict[str, Any] | None = None,
) -> None:
"""Log a browser context lifecycle event."""
entry: dict[str, Any] = {
"type": "context",
"event": event,
"profile": profile,
}
if group_id is not None:
entry["group_id"] = group_id
if tab_id is not None:
entry["tab_id"] = tab_id
if details:
entry["details"] = _truncate(details)
write_log(entry)
def log_cdp_command(
tab_id: int,
method: str,
params: dict[str, Any] | None = None,
result: dict[str, Any] | None = None,
error: str | None = None,
duration_ms: float | None = None,
) -> None:
"""Log a CDP command."""
entry: dict[str, Any] = {
"type": "cdp",
"tab_id": tab_id,
"method": method,
}
if params:
entry["params"] = _sanitize_params(params)
if result:
# CDP results can be large, truncate aggressively
entry["result"] = _truncate(result, max_len=300)
if error:
entry["error"] = error
if duration_ms is not None:
entry["duration_ms"] = round(duration_ms, 2)
write_log(entry)
def log_connection_event(
event: str, # "connect", "disconnect", "hello"
details: dict[str, Any] | None = None,
) -> None:
"""Log a connection event."""
entry: dict[str, Any] = {
"type": "connection",
"event": event,
}
if details:
entry["details"] = _truncate(details)
write_log(entry)
# Decorator for instrumenting tool functions
def instrument_tool(tool_name: str) -> Callable[[F], F]:
"""Decorator to log tool calls with timing and error handling."""
def decorator(func: F) -> F:
if asyncio.iscoroutinefunction(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = await func(*args, **kwargs)
duration_ms = (time.perf_counter() - start) * 1000
log_tool_call(tool_name, kwargs, result=result, duration_ms=duration_ms)
return result
except Exception as e:
duration_ms = (time.perf_counter() - start) * 1000
log_tool_call(tool_name, kwargs, error=e, duration_ms=duration_ms)
raise
return async_wrapper # type: ignore
else:
@functools.wraps(func)
def sync_wrapper(*args, **kwargs):
start = time.perf_counter()
try:
result = func(*args, **kwargs)
duration_ms = (time.perf_counter() - start) * 1000
log_tool_call(tool_name, kwargs, result=result, duration_ms=duration_ms)
return result
except Exception as e:
duration_ms = (time.perf_counter() - start) * 1000
log_tool_call(tool_name, kwargs, error=e, duration_ms=duration_ms)
raise
return sync_wrapper # type: ignore
return decorator
# Import asyncio at the end to avoid circular import issues
import asyncio
+5 -6
View File
@@ -8,11 +8,13 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from typing import Literal from typing import Literal
from fastmcp import FastMCP from fastmcp import FastMCP
from ..bridge import get_bridge from ..bridge import get_bridge
from ..telemetry import log_tool_call
from .tabs import _get_context from .tabs import _get_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -58,9 +60,7 @@ def register_advanced_tools(mcp: FastMCP) -> None:
try: try:
if selector: if selector:
result = await bridge.wait_for_selector( result = await bridge.wait_for_selector(target_tab, selector, timeout_ms=timeout_ms)
target_tab, selector, timeout_ms=timeout_ms
)
if result.get("ok"): if result.get("ok"):
return { return {
"ok": True, "ok": True,
@@ -70,9 +70,7 @@ def register_advanced_tools(mcp: FastMCP) -> None:
} }
return result return result
elif text: elif text:
result = await bridge.wait_for_text( result = await bridge.wait_for_text(target_tab, text, timeout_ms=timeout_ms)
target_tab, text, timeout_ms=timeout_ms
)
if result.get("ok"): if result.get("ok"):
return { return {
"ok": True, "ok": True,
@@ -275,6 +273,7 @@ def register_advanced_tools(mcp: FastMCP) -> None:
try: try:
from pathlib import Path from pathlib import Path
for path in file_paths: for path in file_paths:
if not Path(path).exists(): if not Path(path).exists():
return {"ok": False, "error": f"File not found: {path}"} return {"ok": False, "error": f"File not found: {path}"}
+135 -33
View File
@@ -9,12 +9,14 @@ from __future__ import annotations
import base64 import base64
import json import json
import logging import logging
import time
from typing import Literal from typing import Literal
from fastmcp import FastMCP from fastmcp import FastMCP
from mcp.types import ImageContent, TextContent from mcp.types import ImageContent, TextContent
from ..bridge import get_bridge from ..bridge import get_bridge
from ..telemetry import log_tool_call
from .tabs import _get_context from .tabs import _get_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -47,52 +49,98 @@ def register_inspection_tools(mcp: FastMCP) -> None:
Returns: Returns:
List of content blocks: text metadata + image List of content blocks: text metadata + image
""" """
start = time.perf_counter()
params = {
"tab_id": tab_id,
"profile": profile,
"full_page": full_page,
"selector": selector,
}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return [ result = [
TextContent( TextContent(
type="text", type="text",
text=json.dumps({"ok": False, "error": "Extension not connected"}), text=json.dumps({"ok": False, "error": "Extension not connected"}),
) )
] ]
log_tool_call(
"browser_screenshot",
params,
result={"ok": False, "error": "Extension not connected"},
)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
err_msg = json.dumps({"ok": False, "error": "Browser not started"}) err_msg = json.dumps({"ok": False, "error": "Browser not started"})
log_tool_call(
"browser_screenshot", params, result={"ok": False, "error": "Browser not started"}
)
return [TextContent(type="text", text=err_msg)] return [TextContent(type="text", text=err_msg)]
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return [ result = [
TextContent(type="text", text=json.dumps({"ok": False, "error": "No active tab"})) TextContent(type="text", text=json.dumps({"ok": False, "error": "No active tab"}))
] ]
log_tool_call(
"browser_screenshot", params, result={"ok": False, "error": "No active tab"}
)
return result
try: try:
if selector: if selector:
logger.warning("Element screenshots not supported, capturing full page") logger.warning("Element screenshots not supported, capturing full page")
result = await bridge.screenshot(target_tab, full_page=full_page) screenshot_result = await bridge.screenshot(target_tab, full_page=full_page)
if not result.get("ok"): if not screenshot_result.get("ok"):
return [TextContent(type="text", text=json.dumps(result))] log_tool_call(
"browser_screenshot",
params,
result=screenshot_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return [TextContent(type="text", text=json.dumps(screenshot_result))]
data = result.get("data") data = screenshot_result.get("data")
mime_type = result.get("mimeType", "image/png") mime_type = screenshot_result.get("mimeType", "image/png")
meta = json.dumps({ meta = json.dumps(
"ok": True, {
"tabId": target_tab, "ok": True,
"url": result.get("url", ""), "tabId": target_tab,
"imageType": mime_type.split("/")[-1], "url": screenshot_result.get("url", ""),
"size": len(base64.b64decode(data)) if data else 0, "imageType": mime_type.split("/")[-1],
"fullPage": full_page, "size": len(base64.b64decode(data)) if data else 0,
}) "fullPage": full_page,
}
)
log_tool_call(
"browser_screenshot",
params,
result={
"ok": True,
"size": len(base64.b64decode(data)) if data else 0,
"url": screenshot_result.get("url", ""),
},
duration_ms=(time.perf_counter() - start) * 1000,
)
return [ return [
TextContent(type="text", text=meta), TextContent(type="text", text=meta),
ImageContent(type="image", data=data, mimeType=mime_type), ImageContent(type="image", data=data, mimeType=mime_type),
] ]
except Exception as e: except Exception as e:
log_tool_call(
"browser_screenshot",
params,
error=e,
duration_ms=(time.perf_counter() - start) * 1000,
)
return [TextContent(type="text", text=json.dumps({"ok": False, "error": str(e)}))] return [TextContent(type="text", text=json.dumps({"ok": False, "error": str(e)}))]
@mcp.tool() @mcp.tool()
@@ -121,23 +169,45 @@ def register_inspection_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with the snapshot text tree, URL, and tab ID Dict with the snapshot text tree, URL, and tab ID
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_snapshot", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_snapshot", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_snapshot", params, result=result)
return result
try: try:
result = await bridge.snapshot(target_tab) snapshot_result = await bridge.snapshot(target_tab)
return result log_tool_call(
"browser_snapshot",
params,
result=snapshot_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return snapshot_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_snapshot",
params,
error=e,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
@mcp.tool() @mcp.tool()
async def browser_console( async def browser_console(
@@ -159,13 +229,15 @@ def register_inspection_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with console messages Dict with console messages
""" """
# Console capture requires subscribing to Runtime.consoleAPICalled events result = {
# which needs persistent event handling.
return {
"ok": True, "ok": True,
"message": "Console capture not yet implemented", "message": "Console capture not yet implemented",
"suggestion": "Use browser_evaluate to check specific values or errors", "suggestion": "Use browser_evaluate to check specific values or errors",
} }
log_tool_call(
"browser_console", {"tab_id": tab_id, "profile": profile, "level": level}, result=result
)
return result
@mcp.tool() @mcp.tool()
async def browser_html( async def browser_html(
@@ -184,17 +256,26 @@ def register_inspection_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with HTML content Dict with HTML content
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile, "selector": selector}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_html", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_html", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_html", params, result=result)
return result
try: try:
import json as json_mod import json as json_mod
@@ -208,15 +289,36 @@ def register_inspection_tools(mcp: FastMCP) -> None:
else: else:
script = "document.documentElement.outerHTML" script = "document.documentElement.outerHTML"
result = await bridge.evaluate(target_tab, script) eval_result = await bridge.evaluate(target_tab, script)
if result.get("ok"): if eval_result.get("ok"):
return { result = {
"ok": True, "ok": True,
"tabId": target_tab, "tabId": target_tab,
"html": result.get("result"), "html": eval_result.get("result"),
"selector": selector, "selector": selector,
} }
return result log_tool_call(
"browser_html",
params,
result={
"ok": True,
"selector": selector,
"html_length": len(eval_result.get("result") or ""),
},
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
log_tool_call(
"browser_html",
params,
result=eval_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return eval_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_html", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
+227 -55
View File
@@ -8,11 +8,13 @@ from __future__ import annotations
import asyncio import asyncio
import logging import logging
import time
from typing import Literal from typing import Literal
from fastmcp import FastMCP from fastmcp import FastMCP
from ..bridge import get_bridge from ..bridge import get_bridge
from ..telemetry import log_tool_call
from .tabs import _get_context from .tabs import _get_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -44,29 +46,54 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with click result and coordinates Dict with click result and coordinates
""" """
start = time.perf_counter()
params = {
"selector": selector,
"tab_id": tab_id,
"profile": profile,
"button": button,
"double_click": double_click,
}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_click", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_click", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_click", params, result=result)
return result
try: try:
result = await bridge.click( click_result = await bridge.click(
target_tab, target_tab,
selector, selector,
button=button, button=button,
click_count=2 if double_click else 1, click_count=2 if double_click else 1,
timeout_ms=timeout_ms, timeout_ms=timeout_ms,
) )
return result log_tool_call(
"browser_click",
params,
result=click_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return click_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_click", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_click_coordinate( async def browser_click_coordinate(
@@ -89,23 +116,45 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with click result Dict with click result
""" """
start = time.perf_counter()
params = {"x": x, "y": y, "tab_id": tab_id, "profile": profile, "button": button}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_click_coordinate", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_click_coordinate", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_click_coordinate", params, result=result)
return result
try: try:
result = await bridge.click_coordinate(target_tab, x, y, button=button) click_result = await bridge.click_coordinate(target_tab, x, y, button=button)
return result log_tool_call(
"browser_click_coordinate",
params,
result=click_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return click_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_click_coordinate",
params,
error=e,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
@mcp.tool() @mcp.tool()
async def browser_type( async def browser_type(
@@ -132,20 +181,29 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with type result Dict with type result
""" """
start = time.perf_counter()
params = {"selector": selector, "text": text, "tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_type", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_type", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_type", params, result=result)
return result
try: try:
result = await bridge.type_text( type_result = await bridge.type_text(
target_tab, target_tab,
selector, selector,
text, text,
@@ -153,9 +211,19 @@ def register_interaction_tools(mcp: FastMCP) -> None:
delay_ms=delay_ms, delay_ms=delay_ms,
timeout_ms=timeout_ms, timeout_ms=timeout_ms,
) )
return result log_tool_call(
"browser_type",
params,
result=type_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return type_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_type", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_fill( async def browser_fill(
@@ -209,23 +277,42 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with press result Dict with press result
""" """
start = time.perf_counter()
params = {"key": key, "selector": selector, "tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_press", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_press", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_press", params, result=result)
return result
try: try:
result = await bridge.press_key(target_tab, key, selector=selector) press_result = await bridge.press_key(target_tab, key, selector=selector)
return result log_tool_call(
"browser_press",
params,
result=press_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return press_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_press", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_hover( async def browser_hover(
@@ -246,23 +333,42 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with hover result Dict with hover result
""" """
start = time.perf_counter()
params = {"selector": selector, "tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_hover", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_hover", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_hover", params, result=result)
return result
try: try:
result = await bridge.hover(target_tab, selector, timeout_ms=timeout_ms) hover_result = await bridge.hover(target_tab, selector, timeout_ms=timeout_ms)
return result log_tool_call(
"browser_hover",
params,
result=hover_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return hover_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_hover", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_select( async def browser_select(
@@ -283,23 +389,42 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with select result Dict with select result
""" """
start = time.perf_counter()
params = {"selector": selector, "values": values, "tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_select", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_select", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_select", params, result=result)
return result
try: try:
result = await bridge.select_option(target_tab, selector, values) select_result = await bridge.select_option(target_tab, selector, values)
return result log_tool_call(
"browser_select",
params,
result=select_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return select_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_select", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_scroll( async def browser_scroll(
@@ -320,23 +445,42 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with scroll result Dict with scroll result
""" """
start = time.perf_counter()
params = {"direction": direction, "amount": amount, "tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_scroll", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_scroll", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_scroll", params, result=result)
return result
try: try:
result = await bridge.scroll(target_tab, direction=direction, amount=amount) scroll_result = await bridge.scroll(target_tab, direction=direction, amount=amount)
return result log_tool_call(
"browser_scroll",
params,
result=scroll_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return scroll_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_scroll", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_drag( async def browser_drag(
@@ -362,17 +506,31 @@ def register_interaction_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with drag result Dict with drag result
""" """
drag_start = time.perf_counter()
params = {
"start_selector": start_selector,
"end_selector": end_selector,
"tab_id": tab_id,
"profile": profile,
}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_drag", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_drag", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_drag", params, result=result)
return result
try: try:
# Get coordinates for both elements and perform drag via CDP # Get coordinates for both elements and perform drag via CDP
@@ -397,7 +555,9 @@ def register_interaction_tools(mcp: FastMCP) -> None:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not start_node: if not start_node:
return {"ok": False, "error": f"Start element not found: {start_selector}"} result = {"ok": False, "error": f"Start element not found: {start_selector}"}
log_tool_call("browser_drag", params, result=result)
return result
end_node = None end_node = None
while asyncio.get_event_loop().time() < deadline: while asyncio.get_event_loop().time() < deadline:
@@ -412,15 +572,13 @@ def register_interaction_tools(mcp: FastMCP) -> None:
await asyncio.sleep(0.1) await asyncio.sleep(0.1)
if not end_node: if not end_node:
return {"ok": False, "error": f"End element not found: {end_selector}"} result = {"ok": False, "error": f"End element not found: {end_selector}"}
log_tool_call("browser_drag", params, result=result)
return result
# Get box models # Get box models
start_box = await bridge._cdp( start_box = await bridge._cdp(target_tab, "DOM.getBoxModel", {"nodeId": start_node})
target_tab, "DOM.getBoxModel", {"nodeId": start_node} end_box = await bridge._cdp(target_tab, "DOM.getBoxModel", {"nodeId": end_node})
)
end_box = await bridge._cdp(
target_tab, "DOM.getBoxModel", {"nodeId": end_node}
)
sc = start_box.get("content", []) sc = start_box.get("content", [])
ec = end_box.get("content", []) ec = end_box.get("content", [])
@@ -459,7 +617,7 @@ def register_interaction_tools(mcp: FastMCP) -> None:
}, },
) )
return { result = {
"ok": True, "ok": True,
"action": "drag", "action": "drag",
"from": start_selector, "from": start_selector,
@@ -467,5 +625,19 @@ def register_interaction_tools(mcp: FastMCP) -> None:
"fromCoords": {"x": start_x, "y": start_y}, "fromCoords": {"x": start_x, "y": start_y},
"toCoords": {"x": end_x, "y": end_y}, "toCoords": {"x": end_x, "y": end_y},
} }
log_tool_call(
"browser_drag",
params,
result=result,
duration_ms=(time.perf_counter() - drag_start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_drag",
params,
error=e,
duration_ms=(time.perf_counter() - drag_start) * 1000,
)
return result
+104 -17
View File
@@ -8,11 +8,14 @@ No Playwright required - all operations go through the Chrome extension.
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import Any from typing import Any
from fastmcp import FastMCP from fastmcp import FastMCP
from ..bridge import get_bridge from ..bridge import get_bridge
from ..session import _active_profile
from ..telemetry import log_context_event, log_tool_call
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -20,6 +23,13 @@ logger = logging.getLogger(__name__)
_contexts: dict[str, dict[str, Any]] = {} _contexts: dict[str, dict[str, Any]] = {}
def _resolve_profile(profile: str | None) -> str:
"""Resolve profile name, using context variable if not provided."""
if profile is not None:
return profile
return _active_profile.get()
def register_lifecycle_tools(mcp: FastMCP) -> None: def register_lifecycle_tools(mcp: FastMCP) -> None:
"""Register browser lifecycle management tools.""" """Register browser lifecycle management tools."""
@@ -34,22 +44,27 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with browser status Dict with browser status
""" """
start = time.perf_counter()
params = {"profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return { result = {
"ok": False, "ok": False,
"error": "Browser extension not connected", "error": "Browser extension not connected",
"connected": False, "connected": False,
} }
log_tool_call("browser_status", params, result=result)
return result
profile_name = profile or "default" profile_name = _resolve_profile(profile)
ctx = _contexts.get(profile_name) ctx = _contexts.get(profile_name)
if ctx: if ctx:
try: try:
tabs_result = await bridge.list_tabs(ctx.get("groupId")) tabs_result = await bridge.list_tabs(ctx.get("groupId"))
tabs = tabs_result.get("tabs", []) tabs = tabs_result.get("tabs", [])
return { result = {
"ok": True, "ok": True,
"connected": True, "connected": True,
"profile": profile_name, "profile": profile_name,
@@ -58,22 +73,43 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
"activeTab": ctx.get("activeTabId"), "activeTab": ctx.get("activeTabId"),
"tabs": len(tabs), "tabs": len(tabs),
} }
log_tool_call(
"browser_status",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return { result = {
"ok": True, "ok": True,
"connected": True, "connected": True,
"profile": profile_name, "profile": profile_name,
"running": False, "running": False,
"error": str(e), "error": str(e),
} }
log_tool_call(
"browser_status",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
return { result = {
"ok": True, "ok": True,
"connected": True, "connected": True,
"profile": profile_name, "profile": profile_name,
"running": False, "running": False,
"tabs": 0, "tabs": 0,
} }
log_tool_call(
"browser_status",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
@mcp.tool() @mcp.tool()
async def browser_start(profile: str | None = None) -> dict: async def browser_start(profile: str | None = None) -> dict:
@@ -89,28 +125,39 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with start status including groupId and initial tabId Dict with start status including groupId and initial tabId
""" """
start = time.perf_counter()
params = {"profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return { result = {
"ok": False, "ok": False,
"error": ( "error": (
"Browser extension not connected. " "Browser extension not connected. Install the Beeline extension and connect it."
"Install the Beeline extension and connect it."
), ),
} }
log_tool_call("browser_start", params, result=result)
return result
profile_name = profile or "default" profile_name = _resolve_profile(profile)
# Check if already running # Check if already running
if profile_name in _contexts: if profile_name in _contexts:
ctx = _contexts[profile_name] ctx = _contexts[profile_name]
return { result = {
"ok": True, "ok": True,
"status": "already_running", "status": "already_running",
"profile": profile_name, "profile": profile_name,
"groupId": ctx.get("groupId"), "groupId": ctx.get("groupId"),
"activeTabId": ctx.get("activeTabId"), "activeTabId": ctx.get("activeTabId"),
} }
log_tool_call(
"browser_start",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
try: try:
result = await bridge.create_context(profile_name) result = await bridge.create_context(profile_name)
@@ -129,16 +176,29 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
tab_id, tab_id,
) )
return { log_context_event("start", profile_name, group_id=group_id, tab_id=tab_id)
result = {
"ok": True, "ok": True,
"status": "started", "status": "started",
"profile": profile_name, "profile": profile_name,
"groupId": group_id, "groupId": group_id,
"activeTabId": tab_id, "activeTabId": tab_id,
} }
log_tool_call(
"browser_start",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
logger.exception("Failed to start browser context") logger.exception("Failed to start browser context")
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_start", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_stop(profile: str | None = None) -> dict: async def browser_stop(profile: str | None = None) -> dict:
@@ -151,15 +211,27 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with stop status Dict with stop status
""" """
start = time.perf_counter()
params = {"profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_stop", params, result=result)
return result
profile_name = profile or "default" profile_name = _resolve_profile(profile)
ctx = _contexts.pop(profile_name, None) ctx = _contexts.pop(profile_name, None)
if not ctx: if not ctx:
return {"ok": True, "status": "not_running", "profile": profile_name} result = {"ok": True, "status": "not_running", "profile": profile_name}
log_tool_call(
"browser_stop",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
try: try:
group_id = ctx.get("groupId") group_id = ctx.get("groupId")
@@ -173,12 +245,27 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
closed_tabs, closed_tabs,
) )
return { log_context_event(
"stop", profile_name, group_id=group_id, details={"closed_tabs": closed_tabs}
)
result = {
"ok": True, "ok": True,
"status": "stopped", "status": "stopped",
"profile": profile_name, "profile": profile_name,
"closedTabs": closed_tabs, "closedTabs": closed_tabs,
} }
log_tool_call(
"browser_stop",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
logger.exception("Failed to stop browser context") logger.exception("Failed to stop browser context")
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_stop", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
+111 -26
View File
@@ -7,11 +7,13 @@ All operations go through the Beeline extension via CDP.
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import Literal from typing import Literal
from fastmcp import FastMCP from fastmcp import FastMCP
from ..bridge import get_bridge from ..bridge import get_bridge
from ..telemetry import log_tool_call
from .tabs import _get_context from .tabs import _get_context
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -43,28 +45,51 @@ def register_navigation_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with navigation result (url, title) Dict with navigation result (url, title)
""" """
start = time.perf_counter()
params = {"url": url, "tab_id": tab_id, "profile": profile, "wait_until": wait_until}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_navigate", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_navigate", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab. Open a tab first with browser_open."} result = {"ok": False, "error": "No active tab. Open a tab first with browser_open."}
log_tool_call("browser_navigate", params, result=result)
return result
try: try:
result = await bridge.navigate(target_tab, url, wait_until=wait_until) nav_result = await bridge.navigate(target_tab, url, wait_until=wait_until)
return { result = {
"ok": True, "ok": True,
"tabId": target_tab, "tabId": target_tab,
"url": result.get("url"), "url": nav_result.get("url"),
"title": result.get("title"), "title": nav_result.get("title"),
} }
log_tool_call(
"browser_navigate",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_navigate",
params,
error=e,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
@mcp.tool() @mcp.tool()
async def browser_go_back( async def browser_go_back(
@@ -81,23 +106,42 @@ def register_navigation_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with navigation result Dict with navigation result
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_go_back", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_go_back", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_go_back", params, result=result)
return result
try: try:
result = await bridge.go_back(target_tab) nav_result = await bridge.go_back(target_tab)
return result log_tool_call(
"browser_go_back",
params,
result=nav_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return nav_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_go_back", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_go_forward( async def browser_go_forward(
@@ -114,23 +158,45 @@ def register_navigation_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with navigation result Dict with navigation result
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_go_forward", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_go_forward", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_go_forward", params, result=result)
return result
try: try:
result = await bridge.go_forward(target_tab) nav_result = await bridge.go_forward(target_tab)
return result log_tool_call(
"browser_go_forward",
params,
result=nav_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return nav_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_go_forward",
params,
error=e,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
@mcp.tool() @mcp.tool()
async def browser_reload( async def browser_reload(
@@ -147,20 +213,39 @@ def register_navigation_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with reload result Dict with reload result
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_reload", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_reload", params, result=result)
return result
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No active tab"} result = {"ok": False, "error": "No active tab"}
log_tool_call("browser_reload", params, result=result)
return result
try: try:
result = await bridge.reload(target_tab) nav_result = await bridge.reload(target_tab)
return result log_tool_call(
"browser_reload",
params,
result=nav_result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return nav_result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_reload", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
+128 -23
View File
@@ -7,19 +7,29 @@ All operations go through the Beeline extension - no Playwright required.
from __future__ import annotations from __future__ import annotations
import logging import logging
import time
from typing import Any from typing import Any
from fastmcp import FastMCP from fastmcp import FastMCP
from ..bridge import get_bridge from ..bridge import get_bridge
from ..session import _active_profile
from ..telemetry import log_tool_call
from .lifecycle import _contexts from .lifecycle import _contexts
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_context(profile: str | None = None) -> dict[str, Any] | None: def _get_context(profile: str | None = None) -> dict[str, Any] | None:
"""Get the context for a profile.""" """Get the context for a profile.
profile_name = profile or "default"
If profile is None, uses the _active_profile context variable
(set by subagent executor to the agent_id).
"""
if profile is not None:
profile_name = profile
else:
profile_name = _active_profile.get()
return _contexts.get(profile_name) return _contexts.get(profile_name)
@@ -43,26 +53,44 @@ def register_tab_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with list of tabs and counts Dict with list of tabs and counts
""" """
start = time.perf_counter()
params = {"profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_tabs", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_tabs", params, result=result)
return result
try: try:
result = await bridge.list_tabs(ctx.get("groupId")) result = await bridge.list_tabs(ctx.get("groupId"))
tabs = result.get("tabs", []) tabs = result.get("tabs", [])
return { result = {
"ok": True, "ok": True,
"tabs": tabs, "tabs": tabs,
"total": len(tabs), "total": len(tabs),
"activeTabId": ctx.get("activeTabId"), "activeTabId": ctx.get("activeTabId"),
} }
log_tool_call(
"browser_tabs",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_tabs", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_open( async def browser_open(
@@ -84,13 +112,20 @@ def register_tab_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with new tab info (id, url, title) Dict with new tab info (id, url, title)
""" """
start = time.perf_counter()
params = {"url": url, "background": background, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_open", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_open", params, result=result)
return result
try: try:
# Create tab in the group # Create tab in the group
@@ -105,15 +140,26 @@ def register_tab_tools(mcp: FastMCP) -> None:
# Navigate and wait for load # Navigate and wait for load
nav_result = await bridge.navigate(tab_id, url, wait_until="load") nav_result = await bridge.navigate(tab_id, url, wait_until="load")
return { result = {
"ok": True, "ok": True,
"tabId": tab_id, "tabId": tab_id,
"url": nav_result.get("url", url), "url": nav_result.get("url", url),
"title": nav_result.get("title", ""), "title": nav_result.get("title", ""),
"background": background, "background": background,
} }
log_tool_call(
"browser_open",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_open", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_close( async def browser_close(
@@ -130,18 +176,27 @@ def register_tab_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with close status Dict with close status
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_close", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_close", params, result=result)
return result
# Use active tab if not specified # Use active tab if not specified
target_tab = tab_id or ctx.get("activeTabId") target_tab = tab_id or ctx.get("activeTabId")
if target_tab is None: if target_tab is None:
return {"ok": False, "error": "No tab to close"} result = {"ok": False, "error": "No tab to close"}
log_tool_call("browser_close", params, result=result)
return result
try: try:
await bridge.close_tab(target_tab) await bridge.close_tab(target_tab)
@@ -152,9 +207,20 @@ def register_tab_tools(mcp: FastMCP) -> None:
tabs = result.get("tabs", []) tabs = result.get("tabs", [])
ctx["activeTabId"] = tabs[0].get("id") if tabs else None ctx["activeTabId"] = tabs[0].get("id") if tabs else None
return {"ok": True, "closed": target_tab} result = {"ok": True, "closed": target_tab}
log_tool_call(
"browser_close",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_close", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_focus(tab_id: int, profile: str | None = None) -> dict: async def browser_focus(tab_id: int, profile: str | None = None) -> dict:
@@ -168,20 +234,38 @@ def register_tab_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with focus status Dict with focus status
""" """
start = time.perf_counter()
params = {"tab_id": tab_id, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_focus", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_focus", params, result=result)
return result
try: try:
await bridge.activate_tab(tab_id) await bridge.activate_tab(tab_id)
ctx["activeTabId"] = tab_id ctx["activeTabId"] = tab_id
return {"ok": True, "tabId": tab_id} result = {"ok": True, "tabId": tab_id}
log_tool_call(
"browser_focus",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_focus", params, error=e, duration_ms=(time.perf_counter() - start) * 1000
)
return result
@mcp.tool() @mcp.tool()
async def browser_close_all( async def browser_close_all(
@@ -199,13 +283,20 @@ def register_tab_tools(mcp: FastMCP) -> None:
Returns: Returns:
Dict with number of closed tabs and remaining count Dict with number of closed tabs and remaining count
""" """
start = time.perf_counter()
params = {"keep_active": keep_active, "profile": profile}
bridge = get_bridge() bridge = get_bridge()
if not bridge or not bridge.is_connected: if not bridge or not bridge.is_connected:
return {"ok": False, "error": "Browser extension not connected"} result = {"ok": False, "error": "Browser extension not connected"}
log_tool_call("browser_close_all", params, result=result)
return result
ctx = _get_context(profile) ctx = _get_context(profile)
if not ctx: if not ctx:
return {"ok": False, "error": "Browser not started. Call browser_start first."} result = {"ok": False, "error": "Browser not started. Call browser_start first."}
log_tool_call("browser_close_all", params, result=result)
return result
try: try:
result = await bridge.list_tabs(ctx.get("groupId")) result = await bridge.list_tabs(ctx.get("groupId"))
@@ -231,13 +322,27 @@ def register_tab_tools(mcp: FastMCP) -> None:
remaining = result.get("tabs", []) remaining = result.get("tabs", [])
ctx["activeTabId"] = remaining[0].get("id") if remaining else None ctx["activeTabId"] = remaining[0].get("id") if remaining else None
return { result = {
"ok": True, "ok": True,
"closed_count": closed, "closed_count": closed,
"remaining": len(tabs) - closed, "remaining": len(tabs) - closed,
} }
log_tool_call(
"browser_close_all",
params,
result=result,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
except Exception as e: except Exception as e:
return {"ok": False, "error": str(e)} result = {"ok": False, "error": str(e)}
log_tool_call(
"browser_close_all",
params,
error=e,
duration_ms=(time.perf_counter() - start) * 1000,
)
return result
@mcp.tool() @mcp.tool()
async def browser_close_finished( async def browser_close_finished(
Generated
+103
View File
@@ -1145,6 +1145,58 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" }, { url = "https://files.pythonhosted.org/packages/c4/ab/09169d5a4612a5f92490806649ac8d41e3ec9129c636754575b3553f4ea4/googleapis_common_protos-1.72.0-py3-none-any.whl", hash = "sha256:4299c5a82d5ae1a9702ada957347726b167f9f8d1fc352477702a1e851ff4038", size = 297515, upload-time = "2025-11-06T18:29:13.14Z" },
] ]
[[package]]
name = "greenlet"
version = "3.3.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/47/16400cb42d18d7a6bb46f0626852c1718612e35dcb0dffa16bbaffdf5dd2/greenlet-3.3.2-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:c56692189a7d1c7606cb794be0a8381470d95c57ce5be03fb3d0ef57c7853b86", size = 278890, upload-time = "2026-02-20T20:19:39.263Z" },
{ url = "https://files.pythonhosted.org/packages/a3/90/42762b77a5b6aa96cd8c0e80612663d39211e8ae8a6cd47c7f1249a66262/greenlet-3.3.2-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ebd458fa8285960f382841da585e02201b53a5ec2bac6b156fc623b5ce4499f", size = 581120, upload-time = "2026-02-20T20:47:30.161Z" },
{ url = "https://files.pythonhosted.org/packages/bf/6f/f3d64f4fa0a9c7b5c5b3c810ff1df614540d5aa7d519261b53fba55d4df9/greenlet-3.3.2-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a443358b33c4ec7b05b79a7c8b466f5d275025e750298be7340f8fc63dff2a55", size = 594363, upload-time = "2026-02-20T20:55:56.965Z" },
{ url = "https://files.pythonhosted.org/packages/9c/8b/1430a04657735a3f23116c2e0d5eb10220928846e4537a938a41b350bed6/greenlet-3.3.2-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4375a58e49522698d3e70cc0b801c19433021b5c37686f7ce9c65b0d5c8677d2", size = 605046, upload-time = "2026-02-20T21:02:45.234Z" },
{ url = "https://files.pythonhosted.org/packages/72/83/3e06a52aca8128bdd4dcd67e932b809e76a96ab8c232a8b025b2850264c5/greenlet-3.3.2-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e2cd90d413acbf5e77ae41e5d3c9b3ac1d011a756d7284d7f3f2b806bbd6358", size = 594156, upload-time = "2026-02-20T20:20:59.955Z" },
{ url = "https://files.pythonhosted.org/packages/70/79/0de5e62b873e08fe3cef7dbe84e5c4bc0e8ed0c7ff131bccb8405cd107c8/greenlet-3.3.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:442b6057453c8cb29b4fb36a2ac689382fc71112273726e2423f7f17dc73bf99", size = 1554649, upload-time = "2026-02-20T20:49:32.293Z" },
{ url = "https://files.pythonhosted.org/packages/5a/00/32d30dee8389dc36d42170a9c66217757289e2afb0de59a3565260f38373/greenlet-3.3.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:45abe8eb6339518180d5a7fa47fa01945414d7cca5ecb745346fc6a87d2750be", size = 1619472, upload-time = "2026-02-20T20:21:07.966Z" },
{ url = "https://files.pythonhosted.org/packages/f1/3a/efb2cf697fbccdf75b24e2c18025e7dfa54c4f31fab75c51d0fe79942cef/greenlet-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:1e692b2dae4cc7077cbb11b47d258533b48c8fde69a33d0d8a82e2fe8d8531d5", size = 230389, upload-time = "2026-02-20T20:17:18.772Z" },
{ url = "https://files.pythonhosted.org/packages/e1/a1/65bbc059a43a7e2143ec4fc1f9e3f673e04f9c7b371a494a101422ac4fd5/greenlet-3.3.2-cp311-cp311-win_arm64.whl", hash = "sha256:02b0a8682aecd4d3c6c18edf52bc8e51eacdd75c8eac52a790a210b06aa295fd", size = 229645, upload-time = "2026-02-20T20:18:18.695Z" },
{ url = "https://files.pythonhosted.org/packages/ea/ab/1608e5a7578e62113506740b88066bf09888322a311cff602105e619bd87/greenlet-3.3.2-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:ac8d61d4343b799d1e526db579833d72f23759c71e07181c2d2944e429eb09cd", size = 280358, upload-time = "2026-02-20T20:17:43.971Z" },
{ url = "https://files.pythonhosted.org/packages/a5/23/0eae412a4ade4e6623ff7626e38998cb9b11e9ff1ebacaa021e4e108ec15/greenlet-3.3.2-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ceec72030dae6ac0c8ed7591b96b70410a8be370b6a477b1dbc072856ad02bd", size = 601217, upload-time = "2026-02-20T20:47:31.462Z" },
{ url = "https://files.pythonhosted.org/packages/f8/16/5b1678a9c07098ecb9ab2dd159fafaf12e963293e61ee8d10ecb55273e5e/greenlet-3.3.2-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a2a5be83a45ce6188c045bcc44b0ee037d6a518978de9a5d97438548b953a1ac", size = 611792, upload-time = "2026-02-20T20:55:58.423Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c5/cc09412a29e43406eba18d61c70baa936e299bc27e074e2be3806ed29098/greenlet-3.3.2-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ae9e21c84035c490506c17002f5c8ab25f980205c3e61ddb3a2a2a2e6c411fcb", size = 626250, upload-time = "2026-02-20T21:02:46.596Z" },
{ url = "https://files.pythonhosted.org/packages/50/1f/5155f55bd71cabd03765a4aac9ac446be129895271f73872c36ebd4b04b6/greenlet-3.3.2-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43e99d1749147ac21dde49b99c9abffcbc1e2d55c67501465ef0930d6e78e070", size = 613875, upload-time = "2026-02-20T20:21:01.102Z" },
{ url = "https://files.pythonhosted.org/packages/fc/dd/845f249c3fcd69e32df80cdab059b4be8b766ef5830a3d0aa9d6cad55beb/greenlet-3.3.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4c956a19350e2c37f2c48b336a3afb4bff120b36076d9d7fb68cb44e05d95b79", size = 1571467, upload-time = "2026-02-20T20:49:33.495Z" },
{ url = "https://files.pythonhosted.org/packages/2a/50/2649fe21fcc2b56659a452868e695634722a6655ba245d9f77f5656010bf/greenlet-3.3.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c6f8ba97d17a1e7d664151284cb3315fc5f8353e75221ed4324f84eb162b395", size = 1640001, upload-time = "2026-02-20T20:21:09.154Z" },
{ url = "https://files.pythonhosted.org/packages/9b/40/cc802e067d02af8b60b6771cea7d57e21ef5e6659912814babb42b864713/greenlet-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:34308836d8370bddadb41f5a7ce96879b72e2fdfb4e87729330c6ab52376409f", size = 231081, upload-time = "2026-02-20T20:17:28.121Z" },
{ url = "https://files.pythonhosted.org/packages/58/2e/fe7f36ff1982d6b10a60d5e0740c759259a7d6d2e1dc41da6d96de32fff6/greenlet-3.3.2-cp312-cp312-win_arm64.whl", hash = "sha256:d3a62fa76a32b462a97198e4c9e99afb9ab375115e74e9a83ce180e7a496f643", size = 230331, upload-time = "2026-02-20T20:17:23.34Z" },
{ url = "https://files.pythonhosted.org/packages/ac/48/f8b875fa7dea7dd9b33245e37f065af59df6a25af2f9561efa8d822fde51/greenlet-3.3.2-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:aa6ac98bdfd716a749b84d4034486863fd81c3abde9aa3cf8eff9127981a4ae4", size = 279120, upload-time = "2026-02-20T20:19:01.9Z" },
{ url = "https://files.pythonhosted.org/packages/49/8d/9771d03e7a8b1ee456511961e1b97a6d77ae1dea4a34a5b98eee706689d3/greenlet-3.3.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ab0c7e7901a00bc0a7284907273dc165b32e0d109a6713babd04471327ff7986", size = 603238, upload-time = "2026-02-20T20:47:32.873Z" },
{ url = "https://files.pythonhosted.org/packages/59/0e/4223c2bbb63cd5c97f28ffb2a8aee71bdfb30b323c35d409450f51b91e3e/greenlet-3.3.2-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d248d8c23c67d2291ffd47af766e2a3aa9fa1c6703155c099feb11f526c63a92", size = 614219, upload-time = "2026-02-20T20:55:59.817Z" },
{ url = "https://files.pythonhosted.org/packages/94/2b/4d012a69759ac9d77210b8bfb128bc621125f5b20fc398bce3940d036b1c/greenlet-3.3.2-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ccd21bb86944ca9be6d967cf7691e658e43417782bce90b5d2faeda0ff78a7dd", size = 628268, upload-time = "2026-02-20T21:02:48.024Z" },
{ url = "https://files.pythonhosted.org/packages/7a/34/259b28ea7a2a0c904b11cd36c79b8cef8019b26ee5dbe24e73b469dea347/greenlet-3.3.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6997d360a4e6a4e936c0f9625b1c20416b8a0ea18a8e19cabbefc712e7397ab", size = 616774, upload-time = "2026-02-20T20:21:02.454Z" },
{ url = "https://files.pythonhosted.org/packages/0a/03/996c2d1689d486a6e199cb0f1cf9e4aa940c500e01bdf201299d7d61fa69/greenlet-3.3.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:64970c33a50551c7c50491671265d8954046cb6e8e2999aacdd60e439b70418a", size = 1571277, upload-time = "2026-02-20T20:49:34.795Z" },
{ url = "https://files.pythonhosted.org/packages/d9/c4/2570fc07f34a39f2caf0bf9f24b0a1a0a47bc2e8e465b2c2424821389dfc/greenlet-3.3.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1a9172f5bf6bd88e6ba5a84e0a68afeac9dc7b6b412b245dd64f52d83c81e55b", size = 1640455, upload-time = "2026-02-20T20:21:10.261Z" },
{ url = "https://files.pythonhosted.org/packages/91/39/5ef5aa23bc545aa0d31e1b9b55822b32c8da93ba657295840b6b34124009/greenlet-3.3.2-cp313-cp313-win_amd64.whl", hash = "sha256:a7945dd0eab63ded0a48e4dcade82939783c172290a7903ebde9e184333ca124", size = 230961, upload-time = "2026-02-20T20:16:58.461Z" },
{ url = "https://files.pythonhosted.org/packages/62/6b/a89f8456dcb06becff288f563618e9f20deed8dd29beea14f9a168aef64b/greenlet-3.3.2-cp313-cp313-win_arm64.whl", hash = "sha256:394ead29063ee3515b4e775216cb756b2e3b4a7e55ae8fd884f17fa579e6b327", size = 230221, upload-time = "2026-02-20T20:17:37.152Z" },
{ url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" },
{ url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" },
{ url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" },
{ url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" },
{ url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" },
{ url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" },
{ url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" },
{ url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" },
{ url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" },
{ url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" },
{ url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" },
{ url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" },
{ url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" },
{ url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" },
{ url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" },
]
[[package]] [[package]]
name = "grpcio" name = "grpcio"
version = "1.78.0" version = "1.78.0"
@@ -2209,6 +2261,37 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" }, { url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
] ]
[[package]]
name = "playwright"
version = "1.58.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet" },
{ name = "pyee" },
]
wheels = [
{ url = "https://files.pythonhosted.org/packages/f8/c9/9c6061d5703267f1baae6a4647bfd1862e386fbfdb97d889f6f6ae9e3f64/playwright-1.58.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:96e3204aac292ee639edbfdef6298b4be2ea0a55a16b7068df91adac077cc606", size = 42251098, upload-time = "2026-01-30T15:09:24.028Z" },
{ url = "https://files.pythonhosted.org/packages/e0/40/59d34a756e02f8c670f0fee987d46f7ee53d05447d43cd114ca015cb168c/playwright-1.58.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:70c763694739d28df71ed578b9c8202bb83e8fe8fb9268c04dd13afe36301f71", size = 41039625, upload-time = "2026-01-30T15:09:27.558Z" },
{ url = "https://files.pythonhosted.org/packages/e1/ee/3ce6209c9c74a650aac9028c621f357a34ea5cd4d950700f8e2c4b7fe2c4/playwright-1.58.0-py3-none-macosx_11_0_universal2.whl", hash = "sha256:185e0132578733d02802dfddfbbc35f42be23a45ff49ccae5081f25952238117", size = 42251098, upload-time = "2026-01-30T15:09:30.461Z" },
{ url = "https://files.pythonhosted.org/packages/f1/af/009958cbf23fac551a940d34e3206e6c7eed2b8c940d0c3afd1feb0b0589/playwright-1.58.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:c95568ba1eda83812598c1dc9be60b4406dffd60b149bc1536180ad108723d6b", size = 46235268, upload-time = "2026-01-30T15:09:33.787Z" },
{ url = "https://files.pythonhosted.org/packages/d9/a6/0e66ad04b6d3440dae73efb39540c5685c5fc95b17c8b29340b62abbd952/playwright-1.58.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f9999948f1ab541d98812de25e3a8c410776aa516d948807140aff797b4bffa", size = 45964214, upload-time = "2026-01-30T15:09:36.751Z" },
{ url = "https://files.pythonhosted.org/packages/0e/4b/236e60ab9f6d62ed0fd32150d61f1f494cefbf02304c0061e78ed80c1c32/playwright-1.58.0-py3-none-win32.whl", hash = "sha256:1e03be090e75a0fabbdaeab65ce17c308c425d879fa48bb1d7986f96bfad0b99", size = 36815998, upload-time = "2026-01-30T15:09:39.627Z" },
{ url = "https://files.pythonhosted.org/packages/41/f8/5ec599c5e59d2f2f336a05b4f318e733077cd5044f24adb6f86900c3e6a7/playwright-1.58.0-py3-none-win_amd64.whl", hash = "sha256:a2bf639d0ce33b3ba38de777e08697b0d8f3dc07ab6802e4ac53fb65e3907af8", size = 36816005, upload-time = "2026-01-30T15:09:42.449Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c4/cc0229fea55c87d6c9c67fe44a21e2cd28d1d558a5478ed4d617e9fb0c93/playwright-1.58.0-py3-none-win_arm64.whl", hash = "sha256:32ffe5c303901a13a0ecab91d1c3f74baf73b84f4bedbb6b935f5bc11cc98e1b", size = 33085919, upload-time = "2026-01-30T15:09:45.71Z" },
]
[[package]]
name = "playwright-stealth"
version = "2.0.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "playwright" },
]
sdist = { url = "https://files.pythonhosted.org/packages/61/ee/871901103c7b2a12070011fd4d978191f8f962837bf8bb51847274f528fa/playwright_stealth-2.0.2.tar.gz", hash = "sha256:ac57e51873190da5e653e03720e948c8f0a3d06b098f1d56763103d23ee48143", size = 24902, upload-time = "2026-02-13T02:36:25.137Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/30/f95f087f4b071611a7f63a2a0c9af4df3ac046dae2a693bfdacd70512867/playwright_stealth-2.0.2-py3-none-any.whl", hash = "sha256:37a5733f481b9c0ad602cf71491aa5a7c96c2a2fe4fa1e7ab764d2cd35520f2f", size = 33209, upload-time = "2026-02-13T02:36:26.334Z" },
]
[[package]] [[package]]
name = "pluggy" name = "pluggy"
version = "1.6.0" version = "1.6.0"
@@ -2638,6 +2721,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/14/98/73427d065c067a99de6afbe24df3d90cf20d63152ceb42edff2b6e829d4c/pydocket-0.17.5-py3-none-any.whl", hash = "sha256:544d7c2625a33e52528ac24db25794841427dfc2cf30b9c558ac387c77746241", size = 93355, upload-time = "2026-01-30T18:44:37.972Z" }, { url = "https://files.pythonhosted.org/packages/14/98/73427d065c067a99de6afbe24df3d90cf20d63152ceb42edff2b6e829d4c/pydocket-0.17.5-py3-none-any.whl", hash = "sha256:544d7c2625a33e52528ac24db25794841427dfc2cf30b9c558ac387c77746241", size = 93355, upload-time = "2026-01-30T18:44:37.972Z" },
] ]
[[package]]
name = "pyee"
version = "13.0.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/04/e7c1fe4dc78a6fdbfd6c337b1c3732ff543b8a397683ab38378447baa331/pyee-13.0.1.tar.gz", hash = "sha256:0b931f7c14535667ed4c7e0d531716368715e860b988770fc7eb8578d1f67fc8", size = 31655, upload-time = "2026-02-14T21:12:28.044Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a0/c4/b4d4827c93ef43c01f599ef31453ccc1c132b353284fc6c87d535c233129/pyee-13.0.1-py3-none-any.whl", hash = "sha256:af2f8fede4171ef667dfded53f96e2ed0d6e6bd7ee3bb46437f77e3b57689228", size = 15659, upload-time = "2026-02-14T21:12:26.263Z" },
]
[[package]] [[package]]
name = "pygments" name = "pygments"
version = "2.19.2" version = "2.19.2"
@@ -3421,6 +3516,8 @@ all = [
{ name = "google-cloud-bigquery" }, { name = "google-cloud-bigquery" },
{ name = "openpyxl" }, { name = "openpyxl" },
{ name = "pillow" }, { name = "pillow" },
{ name = "playwright" },
{ name = "playwright-stealth" },
{ name = "pytesseract" }, { name = "pytesseract" },
{ name = "restrictedpython" }, { name = "restrictedpython" },
] ]
@@ -3429,6 +3526,8 @@ bigquery = [
] ]
browser = [ browser = [
{ name = "pillow" }, { name = "pillow" },
{ name = "playwright" },
{ name = "playwright-stealth" },
] ]
databricks = [ databricks = [
{ name = "databricks-mcp" }, { name = "databricks-mcp" },
@@ -3486,6 +3585,10 @@ requires-dist = [
{ name = "pillow", marker = "extra == 'all'", specifier = ">=10.0.0" }, { name = "pillow", marker = "extra == 'all'", specifier = ">=10.0.0" },
{ name = "pillow", marker = "extra == 'browser'", specifier = ">=10.0.0" }, { name = "pillow", marker = "extra == 'browser'", specifier = ">=10.0.0" },
{ name = "pillow", marker = "extra == 'ocr'", specifier = ">=10.0.0" }, { name = "pillow", marker = "extra == 'ocr'", specifier = ">=10.0.0" },
{ name = "playwright", marker = "extra == 'all'", specifier = ">=1.40.0" },
{ name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40.0" },
{ name = "playwright-stealth", marker = "extra == 'all'", specifier = ">=2.0.0" },
{ name = "playwright-stealth", marker = "extra == 'browser'", specifier = ">=2.0.0" },
{ name = "psycopg2-binary", specifier = ">=2.9.0" }, { name = "psycopg2-binary", specifier = ">=2.9.0" },
{ name = "pydantic", specifier = ">=2.0.0" }, { name = "pydantic", specifier = ">=2.0.0" },
{ name = "pypdf", specifier = ">=4.0.0" }, { name = "pypdf", specifier = ">=4.0.0" },