feat: extension browser tools
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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+\"([^\"]*)\")?(.*?)$")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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}"}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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" },
|
||||||
|
|||||||
Reference in New Issue
Block a user