Merge remote-tracking branch 'origin/main' into feat/ui-ux-improvements
# Conflicts: # core/frontend/src/components/SidebarQueenItem.tsx
This commit is contained in:
+12
-1
@@ -44,7 +44,18 @@
|
||||
"WebFetch(domain:docs.litellm.ai)",
|
||||
"Bash(cat /home/timothy/aden/hive/.venv/lib/python3.11/site-packages/litellm-*.dist-info/METADATA)",
|
||||
"Bash(find \"/home/timothy/.hive/agents/queens/queen_brand_design/sessions/session_20260415_100751_d49f4c28/\" -type f -name \"*.json*\" -exec grep -l \"协日\" {} \\\\;)",
|
||||
"Bash(grep -v ':0$')"
|
||||
"Bash(grep -v ':0$')",
|
||||
"Bash(curl -s -m 2 http://127.0.0.1:4002/sse -o /dev/null -w 'status=%{http_code} time=%{time_total}s\\\\n')",
|
||||
"mcp__gcu-tools__browser_status",
|
||||
"mcp__gcu-tools__browser_start",
|
||||
"mcp__gcu-tools__browser_navigate",
|
||||
"mcp__gcu-tools__browser_evaluate",
|
||||
"mcp__gcu-tools__browser_screenshot",
|
||||
"mcp__gcu-tools__browser_open",
|
||||
"mcp__gcu-tools__browser_click_coordinate",
|
||||
"mcp__gcu-tools__browser_get_rect",
|
||||
"mcp__gcu-tools__browser_type_focused",
|
||||
"mcp__gcu-tools__browser_wait"
|
||||
],
|
||||
"additionalDirectories": [
|
||||
"/home/timothy/.hive/skills/writing-hive-skills",
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
{
|
||||
"mcpServers": {}
|
||||
"mcpServers": {
|
||||
"gcu-tools": {
|
||||
"type": "stdio",
|
||||
"command": "uv",
|
||||
"args": ["run", "python", "-m", "gcu.server", "--stdio"],
|
||||
"cwd": "/home/timothy/aden/hive/tools"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ All tools are prefixed with `browser_`:
|
||||
- `browser_screenshot` — visual capture (annotated PNG)
|
||||
<!-- /vision-only -->
|
||||
- `browser_shadow_query`, `browser_get_rect` — locate elements (shadow-piercing via `>>>`)
|
||||
- `browser_coords` — convert image pixels to CSS pixels (always use `css_x/y`, never `physical_x/y`)
|
||||
- `browser_scroll`, `browser_wait` — navigation helpers
|
||||
- `browser_evaluate` — run JavaScript
|
||||
- `browser_close`, `browser_close_finished` — tab cleanup
|
||||
@@ -38,9 +37,9 @@ All tools are prefixed with `browser_`:
|
||||
|
||||
Neither tool is "preferred" universally — they're for different jobs. Default to snapshot on text-heavy static pages, screenshot on SPAs and anything shadow-DOM-heavy. Activate the `browser-automation` skill for the full decision tree.
|
||||
|
||||
## Coordinate rule: always CSS pixels
|
||||
## Coordinate rule
|
||||
|
||||
Chrome DevTools Protocol `Input.dispatchMouseEvent` takes **CSS pixels**, not physical pixels. After a screenshot, use `browser_coords(image_x, image_y)` and feed the returned `css_x/y` (NOT `physical_x/y`) to `browser_click_coordinate`, `browser_hover_coordinate`, `browser_press_at`. Feeding physical pixels on a HiDPI display (DPR=1.6, 2, or 3) overshoots by `DPR×` and clicks land in the wrong place. `getBoundingClientRect()` already returns CSS pixels — pass through unchanged, no DPR multiplication.
|
||||
Every browser tool that takes or returns coordinates operates in **fractions of the viewport (0..1 for both axes)**. Read a target's proportional position off `browser_screenshot` ("~35% from the left, ~20% from the top" → `(0.35, 0.20)`) and pass that to `browser_click_coordinate` / `browser_hover_coordinate` / `browser_press_at`. `browser_get_rect` and `browser_shadow_query` return `rect.cx` / `rect.cy` as fractions. The tools multiply by `cssWidth` / `cssHeight` internally — no scale awareness required. Fractions are used because every vision model (Claude, GPT-4o, Gemini, local VLMs) resizes/tiles images differently; proportions are invariant. Avoid raw `getBoundingClientRect()` via `browser_evaluate` for coord lookup; use `browser_get_rect` instead.
|
||||
|
||||
## System prompt tips for browser nodes
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@
|
||||
"label": "Gemini 3 Flash - Fast",
|
||||
"recommended": false,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 900000
|
||||
"max_context_tokens": 240000
|
||||
},
|
||||
{
|
||||
"id": "gemini-3.1-pro-preview-customtools",
|
||||
"label": "Gemini 3.1 Pro - Best quality",
|
||||
"recommended": true,
|
||||
"max_tokens": 32768,
|
||||
"max_context_tokens": 900000
|
||||
"max_context_tokens": 240000
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -42,25 +42,26 @@ after an interaction unless you need a fresh view.
|
||||
Only fall back to `browser_get_text` for extracting small elements by
|
||||
CSS selector.
|
||||
|
||||
## Coordinates: always CSS pixels
|
||||
## Coordinates
|
||||
|
||||
Chrome DevTools Protocol `Input.dispatchMouseEvent` takes **CSS
|
||||
pixels**, not physical pixels. This is critical and often gets wrong:
|
||||
Every browser tool that takes or returns coordinates operates in
|
||||
**fractions of the viewport (0..1 for both axes)**. Read a target's
|
||||
proportional position off `browser_screenshot` — "this button is
|
||||
~35% from the left, ~20% from the top" → pass `(0.35, 0.20)`.
|
||||
`browser_get_rect` and `browser_shadow_query` return `rect.cx` /
|
||||
`rect.cy` as fractions in the same space. The tools handle the
|
||||
fraction → CSS-px multiplication internally; you do not need to
|
||||
track image pixels, DPR, or any scale factor.
|
||||
|
||||
| Tool | Unit |
|
||||
|---|---|
|
||||
| `browser_click_coordinate(x, y)` | **CSS pixels** |
|
||||
| `browser_hover_coordinate(x, y)` | **CSS pixels** |
|
||||
| `browser_press_at(x, y, key)` | **CSS pixels** |
|
||||
| `getBoundingClientRect()` | already CSS pixels — pass straight through |
|
||||
| `browser_coords(img_x, img_y)` | returns `css_x/y` (use this) and `physical_x/y` (debug only) |
|
||||
Why fractions: every vision model (Claude, GPT-4o, Gemini, local
|
||||
VLMs) resizes or tiles images differently before the model sees the
|
||||
pixels. Proportions survive every such transform; pixel coordinates
|
||||
only "work" per-model and break when you swap backends.
|
||||
|
||||
**Always use `css_x/y`** from `browser_coords`. Feeding `physical_x/y`
|
||||
on a HiDPI display overshoots by `DPR×` — clicks land DPR times too
|
||||
far right and down. On a DPR=1.6 display that's 60% off.
|
||||
|
||||
Never multiply `getBoundingClientRect()` by `devicePixelRatio` — it's
|
||||
already in the right unit.
|
||||
Avoid raw `browser_evaluate` + `getBoundingClientRect()` for coord
|
||||
lookup — that returns CSS px and will be wrong when fed to click
|
||||
tools. Prefer `browser_get_rect` / `browser_shadow_query`, which
|
||||
return fractions.
|
||||
|
||||
## Rich-text editors (X, LinkedIn DMs, Gmail, Reddit, Slack, Discord)
|
||||
|
||||
@@ -88,11 +89,10 @@ reach shadow elements transparently.
|
||||
|
||||
**Shadow-heavy site workflow:**
|
||||
1. `browser_screenshot()` → visual image
|
||||
2. Identify target visually → image coordinate
|
||||
3. `browser_coords(x, y)` → CSS px
|
||||
4. `browser_click_coordinate(css_x, css_y)` → lands via native hit
|
||||
test; inputs get focused regardless of shadow depth
|
||||
5. Type via `browser_type_focused` (no selector needed — types into the
|
||||
2. Identify target visually → pixel `(x, y)` read straight off the image
|
||||
3. `browser_click_coordinate(x, y)` → lands via native hit test;
|
||||
inputs get focused regardless of shadow depth
|
||||
4. Type via `browser_type_focused` (no selector needed — types into the
|
||||
already-focused element), or `browser_type` if you have a selector
|
||||
|
||||
For selector-style access when you know the shadow path:
|
||||
|
||||
@@ -743,6 +743,18 @@ async def create_queen(
|
||||
|
||||
async def _queen_loop():
|
||||
logger.debug("[_queen_loop] Starting queen loop for session %s", session.id)
|
||||
# Scope the browser profile to this session so parallel queens each
|
||||
# drive their own Chrome tab group instead of fighting over "default".
|
||||
# Browser tools run in a stdio MCP subprocess, so we can't set a
|
||||
# contextvar across processes — instead we inject `profile` as a
|
||||
# CONTEXT_PARAM that ToolRegistry passes into every MCP call. The
|
||||
# token stays local to this task.
|
||||
try:
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
|
||||
ToolRegistry.set_execution_context(profile=session.id)
|
||||
except Exception:
|
||||
logger.debug("Queen: failed to set browser profile for session %s", session.id, exc_info=True)
|
||||
try:
|
||||
lc = _queen_loop_config
|
||||
queen_loop_config = LoopConfig(
|
||||
|
||||
@@ -28,17 +28,6 @@ from framework.config import QUEENS_DIR
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _stop_live_sessions(manager, keep_session_id: str | None = None) -> None:
|
||||
"""Stop live sessions so only the selected queen session remains active."""
|
||||
for session in list(manager.list_sessions()):
|
||||
if keep_session_id and session.id == keep_session_id:
|
||||
continue
|
||||
try:
|
||||
await manager.stop_session(session.id)
|
||||
except Exception:
|
||||
logger.debug("Failed to stop session %s during queen switch", session.id)
|
||||
|
||||
|
||||
def _read_queen_session_meta(queen_id: str, session_id: str) -> dict[str, Any]:
|
||||
"""Return persisted metadata for a queen session when available."""
|
||||
session_dir = QUEENS_DIR / queen_id / "sessions" / session_id
|
||||
@@ -267,10 +256,6 @@ async def handle_queen_session(request: web.Request) -> web.Response:
|
||||
}
|
||||
)
|
||||
|
||||
# Stop any live sessions bound to a different queen so only one queen
|
||||
# is active at a time.
|
||||
await _stop_live_sessions(manager)
|
||||
|
||||
# 2. Find the most recent cold session for this queen and resume it.
|
||||
# IMPORTANT: skip sessions that don't belong in the queen DM:
|
||||
# - ``colony_fork: true`` -- duplicates created by handle_colony_spawn
|
||||
@@ -361,7 +346,6 @@ async def handle_select_queen_session(request: web.Request) -> web.Response:
|
||||
|
||||
live_session = manager.get_session(target_session_id)
|
||||
if live_session is not None:
|
||||
await _stop_live_sessions(manager, keep_session_id=target_session_id)
|
||||
return web.json_response(
|
||||
{
|
||||
"session_id": live_session.id,
|
||||
@@ -370,8 +354,6 @@ async def handle_select_queen_session(request: web.Request) -> web.Response:
|
||||
}
|
||||
)
|
||||
|
||||
await _stop_live_sessions(manager)
|
||||
|
||||
meta = _read_queen_session_meta(queen_id, target_session_id)
|
||||
agent_path = meta.get("agent_path")
|
||||
initial_phase = None if agent_path else "independent"
|
||||
@@ -405,7 +387,6 @@ async def handle_new_queen_session(request: web.Request) -> web.Response:
|
||||
initial_prompt = body.get("initial_prompt")
|
||||
initial_phase = body.get("initial_phase") or "independent"
|
||||
|
||||
await _stop_live_sessions(manager)
|
||||
session = await manager.create_session(
|
||||
initial_prompt=initial_prompt,
|
||||
queen_name=queen_id,
|
||||
|
||||
@@ -671,8 +671,21 @@ class SessionManager:
|
||||
event_bus=session.event_bus,
|
||||
)
|
||||
|
||||
# Start the worker's agent loop in the background
|
||||
session.queen_task = asyncio.create_task(session.queen_executor.run(initial_message=initial_prompt))
|
||||
# Start the worker's agent loop in the background.
|
||||
# Scope browser profile per-session so parallel sessions drive
|
||||
# independent Chrome tab groups. Browser tools live in an MCP
|
||||
# subprocess; we inject `profile` via the ToolRegistry execution
|
||||
# context (a CONTEXT_PARAM) so it flows into every tool call.
|
||||
async def _run_worker():
|
||||
try:
|
||||
from framework.loader.tool_registry import ToolRegistry
|
||||
|
||||
ToolRegistry.set_execution_context(profile=session.id)
|
||||
except Exception:
|
||||
logger.debug("Worker: failed to set browser profile", exc_info=True)
|
||||
await session.queen_executor.run(initial_message=initial_prompt)
|
||||
|
||||
session.queen_task = asyncio.create_task(_run_worker())
|
||||
|
||||
# Set up event persistence
|
||||
if session.event_bus and queen_dir:
|
||||
|
||||
@@ -638,13 +638,17 @@ class TestQueenSessionSelection:
|
||||
)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"session_id": "queen_live",
|
||||
"queen_id": "queen_technology",
|
||||
"status": "live",
|
||||
}
|
||||
assert any(call.args == ("other_live",) for call in manager.stop_session.await_args_list)
|
||||
# Assert inside the async-with so app shutdown (which stops
|
||||
# remaining sessions as cleanup) doesn't pollute the assertions.
|
||||
assert data == {
|
||||
"session_id": "queen_live",
|
||||
"queen_id": "queen_technology",
|
||||
"status": "live",
|
||||
}
|
||||
# Other queen's live session must be left running so multiple
|
||||
# queens can stay active in parallel across navigation.
|
||||
manager.stop_session.assert_not_awaited()
|
||||
assert "other_live" in manager._sessions
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_select_queen_session_restores_specific_history_session(self, monkeypatch, tmp_path):
|
||||
@@ -745,18 +749,21 @@ class TestQueenSessionSelection:
|
||||
)
|
||||
assert resp.status == 200
|
||||
data = await resp.json()
|
||||
|
||||
assert data == {
|
||||
"session_id": "fresh_thread",
|
||||
"queen_id": "queen_technology",
|
||||
"status": "created",
|
||||
}
|
||||
manager.stop_session.assert_awaited_once_with("old_live")
|
||||
manager.create_session.assert_awaited_once_with(
|
||||
initial_prompt=None,
|
||||
queen_name="queen_technology",
|
||||
initial_phase="independent",
|
||||
)
|
||||
# Assert inside the async-with so app shutdown (which stops
|
||||
# remaining sessions as cleanup) doesn't pollute the assertions.
|
||||
assert data == {
|
||||
"session_id": "fresh_thread",
|
||||
"queen_id": "queen_technology",
|
||||
"status": "created",
|
||||
}
|
||||
# Other queen's live session must be left running.
|
||||
manager.stop_session.assert_not_awaited()
|
||||
assert "old_live" in manager._sessions
|
||||
manager.create_session.assert_awaited_once_with(
|
||||
initial_prompt=None,
|
||||
queen_name="queen_technology",
|
||||
initial_phase="independent",
|
||||
)
|
||||
|
||||
|
||||
class TestExecution:
|
||||
|
||||
@@ -12,25 +12,22 @@ metadata:
|
||||
|
||||
All GCU browser tools drive a real Chrome instance through the Beeline extension and Chrome DevTools Protocol (CDP). That means clicks, keystrokes, and screenshots are processed by the actual browser's native hit testing, focus, and layout engines — **not** a synthetic event layer. Understanding this unlocks strategies that make hard sites easy.
|
||||
|
||||
## Coordinates: always CSS pixels
|
||||
## Coordinates
|
||||
|
||||
**Chrome DevTools Protocol `Input.dispatchMouseEvent` operates in CSS pixels, not physical pixels.**
|
||||
|
||||
When you call `browser_coords(image_x, image_y)` after a screenshot, the returned dict has both `css_x/y` and `physical_x/y`. **Always use `css_x/y` for clicks, hovers, and key presses.**
|
||||
Every browser tool that takes or returns coordinates operates in **fractions of the viewport (0..1 for both axes)**. Read a target's proportional position off `browser_screenshot` — "this button is about 35% from the left and 20% from the top" → pass `(0.35, 0.20)`. Rect-returning tools (`browser_get_rect`, `browser_shadow_query`, and the `rect` inside `focused_element`) also return fractions. The tools convert to CSS pixels internally before dispatching to Chrome.
|
||||
|
||||
```
|
||||
browser_screenshot() → image (downscaled to 800/900 px wide)
|
||||
browser_coords(img_x, img_y) → {css_x, css_y, physical_x, physical_y}
|
||||
browser_click_coordinate(css_x, css_y) ← USE css_x/y
|
||||
browser_hover_coordinate(css_x, css_y) ← USE css_x/y
|
||||
browser_press_at(css_x, css_y, key) ← USE css_x/y
|
||||
browser_screenshot() → image + cssWidth/cssHeight in meta
|
||||
browser_click_coordinate(x, y) → x, y are fractions 0..1
|
||||
browser_hover_coordinate(x, y) → fractions
|
||||
browser_press_at(x, y, key) → fractions
|
||||
browser_get_rect(selector) → rect → rect.cx / rect.cy are fractions
|
||||
browser_shadow_query(...) → rect → same
|
||||
```
|
||||
|
||||
Feeding `physical_x/y` on a HiDPI display overshoots by DPR× — on a DPR=1.6 laptop, clicks land 60% too far right and down. The ratio between `physicalScale` and `cssScale` tells you the effective DPR.
|
||||
**Why fractions:** every vision model (Claude ~1.15 MP target, GPT-4o 512-px tiles, Gemini, local VLMs) resizes or tiles images differently before the model sees the pixels. Proportions survive every such transform; pixel coordinates only "work" per-model and silently break when you swap backends. Four-decimal precision (`0.0001` ≈ 0.17 CSS px on a 1717-wide viewport) is more than enough for the tightest targets.
|
||||
|
||||
`getBoundingClientRect()` already returns CSS pixels — feed those values straight through to click/hover tools without any DPR multiplication.
|
||||
|
||||
**Exception for zoomed elements:** pages that use `zoom` or `transform: scale()` on a container (LinkedIn's `#interop-outlet`, some embedded iframes) render in a scaled local coordinate space. `getBoundingClientRect` there may not match CDP's hit space. Use `browser_shadow_query` which handles the math, or fall back to visually picking coordinates from a screenshot.
|
||||
**Exception for zoomed elements:** pages that use `zoom` or `transform: scale()` on a container (LinkedIn's `#interop-outlet`, some embedded iframes) render in a scaled local coordinate space. `getBoundingClientRect` there may not match CDP's hit space. Prefer `browser_shadow_query` (which handles the math and returns fractions) or visually pick coordinates from a screenshot. Avoid raw `browser_evaluate` + `getBoundingClientRect()` for coord lookup — that returns CSS px and will be wrong when fed to click tools.
|
||||
|
||||
## Screenshot + coordinates is shadow-agnostic — prefer it on shadow-heavy sites
|
||||
|
||||
@@ -38,7 +35,7 @@ On sites that use Shadow DOM heavily (Reddit's faceplate Web Components, LinkedI
|
||||
|
||||
Why:
|
||||
|
||||
- **CDP hit testing walks shadow roots natively.** `browser_click_coordinate(css_x, css_y)` routes through Chrome's native hit tester, which traverses open shadow roots automatically. You don't need to know the shadow structure.
|
||||
- **CDP hit testing walks shadow roots natively.** `browser_click_coordinate(x, y)` routes through Chrome's native hit tester, which traverses open shadow roots automatically. You don't need to know the shadow structure.
|
||||
- **Keyboard dispatch follows focus** into shadow roots. After a click focuses an input (even one three shadow levels deep), `browser_press(...)` with no selector dispatches keys to `document.activeElement`'s computed focus target.
|
||||
- **Screenshots render the real layout** regardless of DOM implementation.
|
||||
|
||||
@@ -46,12 +43,11 @@ Whereas `wait_for_selector`, `browser_click(selector=...)`, `browser_type(select
|
||||
|
||||
### Recommended workflow on shadow-heavy sites
|
||||
|
||||
1. `browser_screenshot()` → visual image
|
||||
2. Identify the target visually → image pixel `(x, y)` (eyeball from the screenshot)
|
||||
3. `browser_coords(x, y)` → convert to CSS px
|
||||
4. `browser_click_coordinate(css_x, css_y)` → lands on the element via native hit testing; inputs get focused. **The response now includes `focused_element: {tag, id, role, contenteditable, rect, ...}`** — use it to verify you actually focused what you intended.
|
||||
5. `browser_type_focused(text="...")` → dispatches CDP `Input.insertText` to `document.activeElement`. Shadow roots, iframes, Lexical, Draft.js, ProseMirror all just work. Use `browser_type(selector, text)` instead when you have a reliable CSS selector for a light-DOM element.
|
||||
6. Verify via `browser_screenshot` OR `browser_get_attribute` on a known-reachable marker (e.g. check that the Send button's `aria-disabled` flipped to `false`).
|
||||
1. `browser_screenshot()` → JPEG; meta includes `cssWidth`/`cssHeight` for reference.
|
||||
2. Identify the target visually → estimate its proportional position `(fx, fy)` where each is in `0..1`.
|
||||
3. `browser_click_coordinate(fx, fy)` → tool converts to CSS px and dispatches; CDP native hit testing focuses the element. **The response includes `focused_element: {tag, id, role, contenteditable, rect, inFrame?, ...}`** — use it to verify you actually focused what you intended. `rect` is in fractions (same space as your input). When focus is inside a same-origin iframe, the descriptor reports the inner element and adds `inFrame: [...]` breadcrumbs.
|
||||
4. `browser_type_focused(text="...")` → inserts text into `document.activeElement` (traverses into same-origin iframes automatically). Shadow roots, iframes, Lexical, Draft.js, ProseMirror all just work. Use `browser_type(selector, text)` instead when you have a reliable CSS selector for a light-DOM element.
|
||||
5. Verify via `browser_screenshot` OR `browser_get_attribute` on a known-reachable marker (e.g. check that the Send button's `aria-disabled` flipped to `false`).
|
||||
|
||||
### The click→type loop (canonical pattern)
|
||||
|
||||
@@ -80,7 +76,7 @@ browser_shadow_query("reddit-search-large >>> #search-input")
|
||||
browser_get_rect("#interop-outlet >>> #ember37 >>> p")
|
||||
```
|
||||
|
||||
Returns the element's rect in **CSS pixels** (feed directly to click tools). Remember: `browser_type` and `wait_for_selector` do **not** support `>>>` — only shadow_query and get_rect do.
|
||||
Returns the element's rect as **fractions of the viewport** (feed `rect.cx` / `rect.cy` directly to click tools). Remember: `browser_type` and `wait_for_selector` do **not** support `>>>` — only shadow_query and get_rect do.
|
||||
|
||||
## Navigation and waiting
|
||||
|
||||
@@ -220,25 +216,15 @@ Recognized without modifiers: `Enter`, `Tab`, `Escape`, `Backspace`, `Delete`, `
|
||||
## Screenshots
|
||||
|
||||
```
|
||||
browser_screenshot() # viewport, 900 px wide by default
|
||||
browser_screenshot(full_page=True) # full scrollable page
|
||||
browser_screenshot() # viewport, 800 px wide JPEG
|
||||
browser_screenshot(full_page=True) # full scrollable page (overview only — don't click off a full-page shot)
|
||||
browser_screenshot(selector="#header") # clip to element's rect
|
||||
```
|
||||
|
||||
Returns a PNG with automatic downscaling to a target width (default 900 px) plus a JSON metadata block containing `cssWidth`, `devicePixelRatio`, `physicalScale`, `cssScale`, and a `scaleHint` string. The image is also annotated with a highlight rectangle/dot showing the last interaction (click, hover, type) if one happened on this tab.
|
||||
Returns a JPEG (quality 75, ~50–120 KB) at 800 px wide. The pixel width is purely a bandwidth choice; all tool coordinates are fractions of the viewport and are invariant to image size. Metadata includes `imageWidth` (800), `cssWidth`, `cssHeight` (for reference), and `physicalScale`. The image is annotated with a highlight rectangle/dot showing the last interaction (click, hover, type) if one happened on this tab.
|
||||
|
||||
The highlight overlay stays visible on the page for **10 seconds** after each interaction, then fades. Before a screenshot is likely, make sure your click / hover / type happens <10 s before the screenshot.
|
||||
|
||||
### Anatomy of the scale fields
|
||||
|
||||
- `cssWidth` = `window.innerWidth` (CSS px)
|
||||
- `devicePixelRatio` = `window.devicePixelRatio` (often 1.6, 2, or 3 on modern displays)
|
||||
- `physicalScale = png_width / image_width` (how many physical-px per image-px)
|
||||
- `cssScale = cssWidth / image_width` (how many CSS-px per image-px)
|
||||
- Effective DPR = `physicalScale / cssScale` (should match `devicePixelRatio`)
|
||||
|
||||
When converting image coordinates for clicks, always use `cssScale`. The `physicalScale` field is there for debugging HiDPI displays, not for inputs.
|
||||
|
||||
## Scrolling
|
||||
|
||||
- Use large scroll amounts (~2000) when loading more content — sites like Twitter and LinkedIn have lazy loading for paging.
|
||||
@@ -363,7 +349,8 @@ Then pass the most specific selector that uniquely identifies the right input (e
|
||||
- **Typing into a rich-text editor without clicking first → send button stays disabled.** Draft.js (X), Lexical (Gmail, LinkedIn DMs), ProseMirror (Reddit), and React-controlled `contenteditable` elements only register input as "real" when the element received a native focus event — JS-sourced `.focus()` is not enough. `browser_type` now does this automatically via a real CDP pointer click before inserting text, but always verify the submit button's `disabled` state before clicking send. See the "ALWAYS click before typing" section above.
|
||||
- **Using per-character `keyDown` on Lexical / Draft.js editors → keys dispatch but text never appears.** Those editors intercept `beforeinput` and route insertion through their own state machine; raw keyDown events are silently dropped. `browser_type` now uses `Input.insertText` by default (the CDP IME-commit method) which these editors accept cleanly. Only set `use_insert_text=False` when you explicitly need per-keystroke dispatch.
|
||||
- **Leaving a composer with text then trying to navigate → `beforeunload` dialog hangs the bridge.** LinkedIn and several other sites pop a native "unsent message" confirm. `browser_navigate` and `close_tab` both time out against this. Always strip `window.onbeforeunload = null` via `browser_evaluate` before any navigation after typing in a composer, or wrap your logic in a `try/finally` that runs the cleanup block.
|
||||
- **Clicking at physical pixels.** CDP uses CSS px. `browser_coords` returns both for debugging, but always feed `css_x/y` to click tools.
|
||||
- **Click landed in the wrong region (sidebar / header instead of target).** Check `focused_element` in the click response — it's ground truth for what actually got focused, including the `inFrame` breadcrumb when focus ends up inside a same-origin iframe. If it isn't the target (e.g. `className: "msg-conversation-listitem__link"` when you meant to hit a composer), adjust the fraction and retry. Coordinates you pass are fractions of the viewport; the tool multiplies by `cssWidth` / `cssHeight` internally, so a wrong result means your estimated proportion was off — not that any scale went sideways.
|
||||
- **Accidentally passing pixels to click / hover / press_at.** The tools reject any coord outside `[-0.1, 1.5]` with a clear error. If you see that error, you passed a pixel (like 815) instead of a fraction (like 0.475). Use `browser_get_rect` to get exact fractional cx/cy, or read proportions off `browser_screenshot`.
|
||||
- **Calling `wait_for_selector` on a shadow element.** It'll always time out. Use `browser_shadow_query` or the screenshot + coordinate strategy.
|
||||
- **Relying on `innerHTML` in injected scripts on LinkedIn.** Silently discarded. Use `createElement` + `appendChild`.
|
||||
- **Not waiting for SPA hydration.** `wait_until="load"` fires before React/Vue rendering on many sites. Add a 2–3 s sleep before querying for chrome elements.
|
||||
|
||||
@@ -34,7 +34,7 @@ LinkedIn is the hardest mainstream site to automate because it combines **shadow
|
||||
| Pending connection card | `.invitation-card, .invitations-card, [data-test-incoming-invitation-card]` | Filter out "invited you to follow" / "subscribe" cards |
|
||||
| Accept button | `button[aria-label*="Accept"]` within the card scope | Per-card scoping is critical — there are many Accept buttons on the page |
|
||||
|
||||
LinkedIn changes class names aggressively. If a class-based selector breaks, fall back to **`browser_screenshot` → visual identification → `browser_coords` → `browser_click_coordinate`**. The screenshot + coord path works regardless of class-name churn and regardless of shadow DOM.
|
||||
LinkedIn changes class names aggressively. If a class-based selector breaks, fall back to **`browser_screenshot` → visual identification → `browser_click_coordinate`** with the pixel you read straight off the image (screenshots are CSS-sized, no conversion). The screenshot + coord path works regardless of class-name churn and regardless of shadow DOM.
|
||||
|
||||
## Profile Message flow (verified end-to-end 2026-04-11)
|
||||
|
||||
|
||||
@@ -15,7 +15,10 @@ import { useColony } from "@/context/ColonyContext";
|
||||
|
||||
export default function Sidebar() {
|
||||
const navigate = useNavigate();
|
||||
const { colonies, queenProfiles, sidebarCollapsed, setSidebarCollapsed } = useColony();
|
||||
const { colonies, queens, queenProfiles, sidebarCollapsed, setSidebarCollapsed } = useColony();
|
||||
const activeQueenIds = new Set(
|
||||
queens.filter((q) => q.status === "online").map((q) => q.id),
|
||||
);
|
||||
const [coloniesExpanded, setColoniesExpanded] = useState(true);
|
||||
const [queensExpanded, setQueensExpanded] = useState(true);
|
||||
|
||||
@@ -148,7 +151,11 @@ export default function Sidebar() {
|
||||
{queensExpanded && (
|
||||
<div className="flex flex-col gap-0.5 mt-0.5">
|
||||
{queenProfiles.map((queen) => (
|
||||
<SidebarQueenItem key={queen.id} queen={queen} />
|
||||
<SidebarQueenItem
|
||||
key={queen.id}
|
||||
queen={queen}
|
||||
isActive={activeQueenIds.has(queen.id)}
|
||||
/>
|
||||
))}
|
||||
{queenProfiles.length === 0 && (
|
||||
<p className="px-5 py-2 text-xs text-sidebar-muted">
|
||||
|
||||
@@ -4,28 +4,37 @@ import type { QueenProfileSummary } from "@/types/colony";
|
||||
|
||||
interface SidebarQueenItemProps {
|
||||
queen: QueenProfileSummary;
|
||||
isActive?: boolean;
|
||||
}
|
||||
|
||||
export default function SidebarQueenItem({ queen }: SidebarQueenItemProps) {
|
||||
export default function SidebarQueenItem({ queen, isActive }: SidebarQueenItemProps) {
|
||||
const [hasAvatar, setHasAvatar] = useState(true);
|
||||
const avatarUrl = `/api/queen/${queen.id}/avatar`;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
to={`/queen/${queen.id}`}
|
||||
className={({ isActive }) =>
|
||||
className={({ isActive: isRouteActive }) =>
|
||||
`group flex items-center gap-2.5 px-3 py-1.5 mx-2 rounded-md text-sm transition-colors ${
|
||||
isActive
|
||||
isRouteActive
|
||||
? "bg-sidebar-active-bg text-foreground font-medium"
|
||||
: "text-foreground/70 hover:bg-sidebar-item-hover hover:text-foreground"
|
||||
}`
|
||||
}
|
||||
>
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center overflow-hidden">
|
||||
{hasAvatar ? (
|
||||
<img src={avatarUrl} alt={queen.name} className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-primary">{queen.name.charAt(0)}</span>
|
||||
<span className="relative flex-shrink-0 w-6 h-6 rounded-full bg-primary/15 flex items-center justify-center">
|
||||
<span className="w-full h-full rounded-full overflow-hidden flex items-center justify-center">
|
||||
{hasAvatar ? (
|
||||
<img src={avatarUrl} alt={queen.name} className="w-full h-full object-cover" onError={() => setHasAvatar(false)} />
|
||||
) : (
|
||||
<span className="text-[10px] font-bold text-primary">{queen.name.charAt(0)}</span>
|
||||
)}
|
||||
</span>
|
||||
{isActive && (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 w-2 h-2 rounded-full bg-emerald-500 ring-2 ring-sidebar-bg"
|
||||
title="Session running"
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# 🐝 Hive Agent v0.10.2
|
||||
|
||||
> A browser-automation-focused follow-up to **v0.10.1**. Coordinates that flow between the vision model and Chrome are now **fractions of the viewport** instead of screenshot pixels — so the same `(x, y)` works across Claude, GPT-4o, Gemini, and any other VLM regardless of how each one resizes or tiles the image. Plus reliability fixes for queen switching, tab-group isolation, and CI.
|
||||
|
||||
---
|
||||
|
||||
## ✨ Highlights
|
||||
|
||||
- **Model-invariant visual clicks.** Every coordinate-taking browser tool (`browser_click_coordinate`, `browser_hover_coordinate`, `browser_press_at`) and every rect-returning tool (`browser_get_rect`, `browser_shadow_query`, the `rect` inside `focused_element`) now speaks in `0..1` fractions of the viewport. Vision-model pixel resizing no longer silently breaks clicks when you swap backends.
|
||||
- **Queens survive profile/queen switches.** Switching queens no longer tears down the active queen's runtime.
|
||||
- **Tab-group isolation.** Browser tab groups are now namespaced per profile, so stale highlight / attach state can't bleed across profiles when Chrome reuses a tab id.
|
||||
- **Remote browser debugger.** New `scripts/browser_remote.py` + HTML UI give a visual debugging surface for the Chrome extension bridge — live screenshots, coord inspector, and one-click test harness for the GCU tools.
|
||||
- **Greener CI.** All framework/tools test failures resolved and Windows CI is unbroken; full ruff lint + format pass across the codebase.
|
||||
- **Gemini reliability tuning.** `gemini-3-flash-customtools` and `gemini-3.1-pro-preview-customtools` now run with `max_context_tokens: 240000` (down from 900k) — long-context quality on Gemini degrades well before the advertised window, and clamping lower trades headroom for more predictable tool use.
|
||||
|
||||
---
|
||||
|
||||
## 🆕 What's New
|
||||
|
||||
### Browser automation
|
||||
|
||||
- **Fraction-based coordinates** — all click / hover / press / rect tools now use `(0..1, 0..1)` fractions of the viewport. Internally each tool multiplies by the cached `cssWidth` / `cssHeight` before dispatching to CDP. Four-decimal precision (`0.0001` ≈ 0.17 CSS px on a 1717-wide viewport) is sufficient for the tightest targets. (@timothyadenhq)
|
||||
- **`browser_type_focused` — dedicated focused-element typing tool** split out from `browser_type`. Use after `browser_click_coordinate` focuses the target; faster than `browser_press` for multi-character input. (@RichardTang-Aden)
|
||||
- **Multi-mode screenshot tool** — `browser_screenshot` gained viewport / full-page / selector-clip modes and returns `cssWidth` / `cssHeight` in metadata so callers can reason about viewport size if they need to. (@RichardTang-Aden)
|
||||
- **Dashed highlighter for type-focus events** — visual differentiation between click (solid) and type-focus (dashed) highlights on post-interaction screenshots. (@RichardTang-Aden)
|
||||
- **Default 1 ms key delay + prompt tuning** — `browser_type` now uses a 1 ms delay by default (was higher), matching what real rich-text editors expect; related orchestrator prompt improvements. (@RichardTang-Aden)
|
||||
- **Remote browser debugger UI** — `scripts/browser_remote.py` + `scripts/browser_remote_ui.html` provide a live visual surface to exercise the GCU browser bridge (screenshots, click targeting, coord readout). (@RichardTang-Aden)
|
||||
- **Iframe-aware `focused_element`** — same-origin iframe descent (capped at 5 levels), so `focused_element` reports the real inner element instead of `{tag: "iframe"}`. Adds an `inFrame: [...]` breadcrumb when traversed. (@timothyadenhq)
|
||||
|
||||
### Skills & prompts
|
||||
|
||||
- **Browser / LinkedIn automation skills rewritten** around the new fraction convention — "read proportion off the image" workflow, updated rect examples, updated troubleshooting entries. (@timothyadenhq, @RichardTang-Aden)
|
||||
- **GCP skills and prompt improvements** — polish on the browser-edge-cases skill and the queen GCU reference guide. (@RichardTang-Aden)
|
||||
- **Canonical workflow simplified** — slimmer, less prescriptive guidance in the default browser/linkedin skills. (@timothyadenhq)
|
||||
|
||||
### Core / server
|
||||
|
||||
- **Namespaced browser tab groups** — per-profile `tab_group` tracking in `queen_orchestrator` / `session_manager`, with a `clear_tab_highlights(tab_ids)` cleanup hook called on context destruction so stale highlight / attach state can't leak onto reused tab ids. (@timothyadenhq)
|
||||
- **Don't kill the queen on switch** — queen switching no longer invokes the "stop runtime" path, keeping active sessions alive across UI navigation. (@timothyadenhq)
|
||||
|
||||
### LLM & model catalog
|
||||
|
||||
- **Gemini context window clamped to 240k** — `core/framework/llm/model_catalog.json` drops `max_context_tokens` from 900000 → 240000 on both `gemini-3-flash-customtools` (Fast) and `gemini-3.1-pro-preview-customtools` (Best quality). Reduces the chance of context-window-edge failures on long sessions. (@RichardTang-Aden)
|
||||
|
||||
### Developer experience
|
||||
|
||||
- **Codebase-wide ruff clean** — 155 lint errors (70 auto-fixed + 85 manual) resolved across framework and tools; 343 files reformatted. Long-line, missing-import, duplicate-method, and W291 whitespace issues all cleared. (#7058)
|
||||
- **Framework + tools test suite green** — 52 → 0 failures across framework tests (mock LLM `model` attribute, updated skill/prompt assertions, compaction formatting, model catalog) and tools tests (csv_tool paths, browser_evaluate toast wrapper). (#7059)
|
||||
- **Windows CI unbroken** — background-job test uses `sys.executable` + double quotes, CLI entry-point guards against `None` stdout, safe-eval timeout bumped for slower Windows runners. (#7061)
|
||||
|
||||
---
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
- **Fraction-click tab-state leak (`_screenshot_css_scales` NameError)** — `clear_tab_state` raised `NameError` on every tab close and profile teardown because a removed cache was still referenced. Fixed in `tools/src/gcu/browser/tools/inspection.py`.
|
||||
- **Missing highlight cleanup on profile destroy** — introduced `clear_tab_highlights` so orphaned highlight state doesn't reappear when Chrome reuses a tab id on a later profile.
|
||||
- **Queen session shutdown on switch** — switching between queens no longer terminates the active queen's runtime.
|
||||
- **Pruned tool-result sentinel mismatch** — compaction / conversation now accept both `Pruned tool result ...` and `[Pruned tool result ...]` sentinel shapes.
|
||||
- **Mock LLM infinite loop on exhausted scenarios** — `MockStreamingLLM` and `_ByTaskMockLLM` now emit a clean text-stop when scenarios are consumed, unblocking `test_worker_report`.
|
||||
|
||||
---
|
||||
|
||||
## ⬆️ Upgrading from v0.10.1
|
||||
|
||||
No migration steps for stored state — existing `~/.hive/` profiles, queens, and sessions continue to work.
|
||||
|
||||
**Behavior change for direct callers of browser coord tools:** `browser_click_coordinate`, `browser_hover_coordinate`, `browser_press_at`, and rect-returning tools now expect and return **fractions** of the viewport (`0..1` on each axis) instead of screenshot pixels. Agents using the default browser-automation skill get this automatically — the skill was updated alongside the tool change. Only custom code that hardcoded pixel coordinates against the prior 800 px-wide screenshot space needs adjustment: divide by `cssWidth` / `cssHeight` (now exposed in `browser_screenshot` metadata) to convert.
|
||||
|
||||
Pull `main` at the `v0.10.2` tag and restart Hive.
|
||||
@@ -80,33 +80,70 @@ async def _adaptive_poll_sleep(elapsed_s: float) -> None:
|
||||
_interaction_highlights: dict[int, dict] = {}
|
||||
|
||||
|
||||
# Compact descriptor of document.activeElement. Returned by both click()
|
||||
def clear_tab_highlights(tab_ids) -> None:
|
||||
"""Drop cached interaction highlights for the given tab_ids.
|
||||
|
||||
Called when a profile's context is destroyed so stale highlight
|
||||
rects can't reappear on a later tab that Chrome happens to assign
|
||||
the same id. Accepts a single id or any iterable.
|
||||
"""
|
||||
if isinstance(tab_ids, int):
|
||||
tab_ids = (tab_ids,)
|
||||
for tid in tab_ids:
|
||||
_interaction_highlights.pop(tid, None)
|
||||
|
||||
|
||||
# Compact descriptor of the focused element. Returned by both click()
|
||||
# and click_coordinate() so the agent can verify it focused what it
|
||||
# intended, then decide whether to follow up with browser_type_focused(text=...).
|
||||
# Keeping this as a single shared string avoids drift
|
||||
# between the two click paths.
|
||||
# intended. When the outer document's activeElement is an <iframe>,
|
||||
# we recurse into the iframe's document (same-origin only) so the
|
||||
# response describes the real inner element — otherwise the agent
|
||||
# always sees {tag: "iframe"} and can't tell whether it hit the
|
||||
# composer or something else inside the frame (e.g. a sidebar item
|
||||
# in LinkedIn's #interop-outlet messaging overlay).
|
||||
_FOCUSED_ELEMENT_JS = """
|
||||
(function() {
|
||||
function describe(el) {
|
||||
var rect = el.getBoundingClientRect();
|
||||
var attrs = {};
|
||||
for (var i = 0; i < el.attributes.length && i < 10; i++) {
|
||||
attrs[el.attributes[i].name] = el.attributes[i].value.substring(0, 200);
|
||||
}
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
className: el.className || null,
|
||||
name: el.getAttribute('name') || null,
|
||||
type: el.getAttribute('type') || null,
|
||||
role: el.getAttribute('role') || null,
|
||||
contenteditable: el.getAttribute('contenteditable') || null,
|
||||
text: (el.innerText || '').substring(0, 200),
|
||||
value: (el.value !== undefined ? String(el.value).substring(0, 200) : null),
|
||||
attributes: attrs,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
};
|
||||
}
|
||||
var el = document.activeElement;
|
||||
if (!el || el === document.body) return null;
|
||||
var rect = el.getBoundingClientRect();
|
||||
var attrs = {};
|
||||
for (var i = 0; i < el.attributes.length && i < 10; i++) {
|
||||
attrs[el.attributes[i].name] = el.attributes[i].value.substring(0, 200);
|
||||
// Descend into same-origin iframes. Capped at 5 levels of
|
||||
// nesting to bound cost. Cross-origin frames throw on
|
||||
// contentDocument access → we catch and report the outermost
|
||||
// iframe instead.
|
||||
var framePath = [];
|
||||
var depth = 0;
|
||||
while (el && (el.tagName === 'IFRAME' || el.tagName === 'FRAME') && depth < 5) {
|
||||
framePath.push(el.id || el.getAttribute('data-testid') || el.tagName.toLowerCase());
|
||||
var innerDoc = null;
|
||||
try { innerDoc = el.contentDocument; } catch (e) { innerDoc = null; }
|
||||
if (!innerDoc) break;
|
||||
var innerActive = innerDoc.activeElement;
|
||||
if (!innerActive || innerActive === innerDoc.body) break;
|
||||
el = innerActive;
|
||||
depth++;
|
||||
}
|
||||
return {
|
||||
tag: el.tagName.toLowerCase(),
|
||||
id: el.id || null,
|
||||
className: el.className || null,
|
||||
name: el.getAttribute('name') || null,
|
||||
type: el.getAttribute('type') || null,
|
||||
role: el.getAttribute('role') || null,
|
||||
contenteditable: el.getAttribute('contenteditable') || null,
|
||||
text: (el.innerText || '').substring(0, 200),
|
||||
value: (el.value !== undefined ? String(el.value).substring(0, 200) : null),
|
||||
attributes: attrs,
|
||||
rect: { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
|
||||
};
|
||||
var out = describe(el);
|
||||
if (framePath.length) out.inFrame = framePath;
|
||||
return out;
|
||||
})()
|
||||
"""
|
||||
|
||||
@@ -469,6 +506,9 @@ class BeelineBridge:
|
||||
# reattach on the reused id.
|
||||
self._cdp_attached.discard(tab_id)
|
||||
_interaction_highlights.pop(tab_id, None)
|
||||
from .tools.inspection import clear_tab_state
|
||||
|
||||
clear_tab_state(tab_id)
|
||||
return result
|
||||
|
||||
async def list_tabs(self, group_id: int | None = None) -> dict:
|
||||
@@ -937,16 +977,36 @@ class BeelineBridge:
|
||||
async def _read_focused_element(self, tab_id: int) -> dict | None:
|
||||
"""Read document.activeElement and return a compact descriptor.
|
||||
|
||||
Returns None on any failure — never raises. Used by both click
|
||||
paths (selector-based click() and click_coordinate()) so the
|
||||
agent gets the same response shape regardless of which one was
|
||||
called. The descriptor lets the agent answer "did my click land
|
||||
on an editable?" without a second round-trip.
|
||||
The JS returns ``rect`` fields in CSS px (they come straight
|
||||
from ``getBoundingClientRect``). We convert them to fractions
|
||||
of the viewport here so the agent sees a rect in the same
|
||||
coord space it passed to click / hover / press_at.
|
||||
|
||||
Returns None on any failure — never raises.
|
||||
"""
|
||||
try:
|
||||
await self._try_enable_domain(tab_id, "Runtime")
|
||||
result = await self.evaluate(tab_id, _FOCUSED_ELEMENT_JS)
|
||||
return (result or {}).get("result")
|
||||
info = (result or {}).get("result")
|
||||
if info and isinstance(info, dict) and isinstance(info.get("rect"), dict):
|
||||
from .tools.inspection import _viewport_sizes
|
||||
|
||||
vp = _viewport_sizes.get(tab_id)
|
||||
if vp and vp[0] > 0 and vp[1] > 0:
|
||||
cw, ch = float(vp[0]), float(vp[1])
|
||||
r = info["rect"]
|
||||
info["rect"] = {
|
||||
"x": round(r.get("x", 0) / cw, 4),
|
||||
"y": round(r.get("y", 0) / ch, 4),
|
||||
"width": round(r.get("width", 0) / cw, 4),
|
||||
"height": round(r.get("height", 0) / ch, 4),
|
||||
}
|
||||
else:
|
||||
# Degraded: cache missing (no screenshot taken
|
||||
# yet). Leave rect in CSS px and flag it so the
|
||||
# agent can tell.
|
||||
info["rectSpace"] = "css"
|
||||
return info
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@@ -959,18 +1019,11 @@ class BeelineBridge:
|
||||
button_map = {"left": "left", "right": "right", "middle": "middle"}
|
||||
cdp_button = button_map.get(button, "left")
|
||||
|
||||
from .tools.inspection import _screenshot_css_scales, _screenshot_scales
|
||||
|
||||
phys_scale = _screenshot_scales.get(tab_id, "unset")
|
||||
css_scale = _screenshot_css_scales.get(tab_id, "unset")
|
||||
logger.info(
|
||||
"click_coordinate tab=%d: x=%.1f, y=%.1f → CDP Input.dispatchMouseEvent. "
|
||||
"stored_scales: physicalScale=%s, cssScale=%s",
|
||||
"click_coordinate tab=%d: x=%.1f, y=%.1f → CDP Input.dispatchMouseEvent",
|
||||
tab_id,
|
||||
x,
|
||||
y,
|
||||
phys_scale,
|
||||
css_scale,
|
||||
)
|
||||
|
||||
await self._cdp(
|
||||
@@ -1113,7 +1166,9 @@ class BeelineBridge:
|
||||
# element (e.g. via browser_click_coordinate). Just clear the
|
||||
# active element if requested, then insert text directly.
|
||||
if clear_first:
|
||||
await self.evaluate(tab_id, """
|
||||
await self.evaluate(
|
||||
tab_id,
|
||||
"""
|
||||
(function() {
|
||||
const el = document.activeElement;
|
||||
if (!el) return;
|
||||
@@ -1125,7 +1180,8 @@ class BeelineBridge:
|
||||
el.dispatchEvent(new Event('input', {bubbles: true}));
|
||||
}
|
||||
})();
|
||||
""")
|
||||
""",
|
||||
)
|
||||
|
||||
if use_insert_text and delay_ms <= 0:
|
||||
# CDP Input.insertText is the most reliable way to insert
|
||||
@@ -1194,7 +1250,9 @@ class BeelineBridge:
|
||||
)
|
||||
rect = (rect_result or {}).get("result")
|
||||
if rect:
|
||||
await self.highlight_rect(tab_id, rect["x"], rect["y"], rect["w"], rect["h"], label="active element", border_style="dashed")
|
||||
await self.highlight_rect(
|
||||
tab_id, rect["x"], rect["y"], rect["w"], rect["h"], label="active element", border_style="dashed"
|
||||
)
|
||||
return {"ok": True, "action": "type", "selector": selector, "length": len(text)}
|
||||
|
||||
# CDP Input.dispatchKeyEvent modifiers bitmask.
|
||||
|
||||
@@ -46,7 +46,10 @@ TOOL_SCHEMAS: dict[str, dict] = {
|
||||
},
|
||||
},
|
||||
"browser_type_focused": {
|
||||
"description": "Type text into the already-focused element. Use after browser_click_coordinate has focused the target. Faster than browser_press for multi-character input.",
|
||||
"description": (
|
||||
"Type text into the already-focused element. Use after browser_click_coordinate "
|
||||
"has focused the target. Faster than browser_press for multi-character input."
|
||||
),
|
||||
"params": {
|
||||
"text": {"type": "string", "required": True},
|
||||
"tab_id": {"type": "integer"},
|
||||
|
||||
@@ -255,6 +255,17 @@ def register_advanced_tools(mcp: FastMCP) -> None:
|
||||
|
||||
try:
|
||||
result = await bridge.resize(target_tab, width, height)
|
||||
# Invalidate per-tab scale caches — CSS width changed, so the
|
||||
# cached viewport dimensions are stale. Click / rect tools
|
||||
# will re-query innerWidth / innerHeight on next use via
|
||||
# _ensure_viewport_size.
|
||||
try:
|
||||
from .inspection import _screenshot_scales, _viewport_sizes
|
||||
|
||||
_viewport_sizes.pop(target_tab, None)
|
||||
_screenshot_scales.pop(target_tab, None)
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
@@ -23,13 +23,39 @@ from .tabs import _get_context
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Target width for normalized screenshots (px in the delivered image)
|
||||
_SCREENSHOT_WIDTH = 600
|
||||
|
||||
# Maps tab_id -> physical scale: image_coord × scale = physical pixels (for CDP Input events)
|
||||
# Fixed output width for all screenshots (bandwidth default). This
|
||||
# number does NOT affect coordinate semantics — click / hover / press
|
||||
# and rect tools all work in fractions of the viewport (0..1), which
|
||||
# are invariant to whatever resize / tile the vision API applies. The
|
||||
# 800 px width is simply small enough to keep JPEG payloads under
|
||||
# ~150 KB on typical UI screenshots.
|
||||
_SCREENSHOT_WIDTH = 800
|
||||
|
||||
# Per-tab viewport-size cache populated on every browser_screenshot
|
||||
# and on lazy-init inside the click tools. Stores CSS-pixel viewport
|
||||
# dimensions (window.innerWidth / window.innerHeight). Click tools
|
||||
# multiply fractional inputs by these to get CSS coords before
|
||||
# dispatching CDP events; rect tools divide CSS-pixel DOM rects by
|
||||
# these to produce fractions for the agent.
|
||||
_viewport_sizes: dict[int, tuple[int, int]] = {}
|
||||
|
||||
# Optional debug cache — physical-px scale per tab (orig_png_w /
|
||||
# _SCREENSHOT_WIDTH). Logged only; no consumer.
|
||||
_screenshot_scales: dict[int, float] = {}
|
||||
# Maps tab_id -> CSS scale: image_coord × scale = CSS pixels (for DOM APIs / getBoundingClientRect)
|
||||
_screenshot_css_scales: dict[int, float] = {}
|
||||
|
||||
|
||||
def clear_tab_state(tab_ids) -> None:
|
||||
"""Drop cached screenshot scales for the given tab_ids.
|
||||
|
||||
Called when a tab closes or a profile's context is destroyed so stale
|
||||
scale values can't bleed into a later tab that Chrome happens to assign
|
||||
the same id. Accepts a single id or any iterable.
|
||||
"""
|
||||
if isinstance(tab_ids, int):
|
||||
tab_ids = (tab_ids,)
|
||||
for tid in tab_ids:
|
||||
_screenshot_scales.pop(tid, None)
|
||||
|
||||
|
||||
def _resize_and_annotate(
|
||||
@@ -37,18 +63,25 @@ def _resize_and_annotate(
|
||||
css_width: int,
|
||||
dpr: float = 1.0,
|
||||
highlights: list[dict] | None = None,
|
||||
width: int = _SCREENSHOT_WIDTH,
|
||||
) -> tuple[str, float, float]:
|
||||
"""Resize a base64 PNG to _SCREENSHOT_WIDTH wide, annotate highlights.
|
||||
) -> tuple[str, float]:
|
||||
"""Resize the captured PNG down to ``_SCREENSHOT_WIDTH`` (=800 px)
|
||||
and re-encode as JPEG quality 75.
|
||||
|
||||
Returns (new_b64, physical_scale, css_scale) where:
|
||||
physical_scale = physical_px_per_image_px (multiply image coords → physical px)
|
||||
css_scale = css_px_per_image_px (multiply image coords → CSS px for DOM APIs)
|
||||
The image dimensions do NOT determine click coordinates any more —
|
||||
the tools work in viewport fractions. This helper exists purely
|
||||
for bandwidth + annotation overlay. Returns ``(new_b64,
|
||||
physical_scale)`` where ``physical_scale = orig_png_w / output_w``
|
||||
is kept for debug logging.
|
||||
|
||||
Highlights have x,y,w,h in CSS pixels (what getBoundingClientRect returns,
|
||||
and what CDP Input.dispatchMouseEvent accepts).
|
||||
Falls back to original data if Pillow unavailable or resize fails.
|
||||
Highlight rects arrive in CSS px; they're converted to image-space
|
||||
for overlay drawing via the local ``css_to_image = css_width /
|
||||
output_w`` factor (computed inline — no external cache).
|
||||
"""
|
||||
if not css_width or css_width <= 0:
|
||||
# Bridge always supplies css_width from window.innerWidth; only
|
||||
# reach here on a degraded response. Return the raw PNG.
|
||||
return data, 1.0
|
||||
|
||||
try:
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
except ImportError:
|
||||
@@ -58,48 +91,44 @@ def _resize_and_annotate(
|
||||
import struct
|
||||
|
||||
orig_w = struct.unpack(">I", raw[16:20])[0]
|
||||
raw_size_bytes = len(raw)
|
||||
physical_scale = orig_w / width if orig_w and width else 1.0
|
||||
css_scale = (css_width / width) if css_width else (physical_scale / max(dpr, 1.0))
|
||||
physical_scale = orig_w / _SCREENSHOT_WIDTH if orig_w else 1.0
|
||||
logger.warning(
|
||||
"PIL not available — screenshot resize SKIPPED (cannot downscale image). "
|
||||
"raw_size=%d bytes, png_width=%d, css_width=%s, dpr=%s, target_width=%d. "
|
||||
"Returning ORIGINAL image with computed scales: physicalScale=%.4f, cssScale=%.4f. "
|
||||
"Agent must use browser_coords() to convert image positions before clicking.",
|
||||
raw_size_bytes,
|
||||
orig_w,
|
||||
"PIL not available — screenshot resize SKIPPED. "
|
||||
"Returning raw physical-px PNG. physicalScale=%.4f, "
|
||||
"css_width=%d, dpr=%s. Install Pillow for annotation.",
|
||||
physical_scale,
|
||||
css_width,
|
||||
dpr,
|
||||
width,
|
||||
physical_scale,
|
||||
css_scale,
|
||||
)
|
||||
return data, round(physical_scale, 4), round(css_scale, 4)
|
||||
return data, round(physical_scale, 4)
|
||||
|
||||
try:
|
||||
raw = base64.b64decode(data)
|
||||
img = Image.open(io.BytesIO(raw)).convert("RGBA")
|
||||
orig_w, orig_h = img.size
|
||||
|
||||
physical_scale = orig_w / width
|
||||
css_scale = (css_width / width) if css_width else (physical_scale / max(dpr, 1.0))
|
||||
physical_scale = orig_w / _SCREENSHOT_WIDTH
|
||||
new_w = _SCREENSHOT_WIDTH
|
||||
new_h = round(orig_h * new_w / orig_w)
|
||||
if (new_w, new_h) != img.size:
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
# Local CSS → image px factor for overlay draws. Kept local —
|
||||
# not exported, not stored, not leaked to the agent.
|
||||
css_to_image = css_width / _SCREENSHOT_WIDTH
|
||||
|
||||
logger.info(
|
||||
"Screenshot resize: orig=%dx%d → target=%dx%d, css_width=%s, dpr=%s, physicalScale=%.4f, cssScale=%.4f",
|
||||
"Screenshot: orig=%dx%d → out=%dx%d (css_width=%d, dpr=%s), physicalScale=%.4f, css_to_image=%.4f",
|
||||
orig_w,
|
||||
orig_h,
|
||||
width,
|
||||
round(orig_h * width / orig_w),
|
||||
new_w,
|
||||
new_h,
|
||||
css_width,
|
||||
dpr,
|
||||
physical_scale,
|
||||
css_scale,
|
||||
css_to_image,
|
||||
)
|
||||
|
||||
new_w = width
|
||||
new_h = round(orig_h * new_w / orig_w)
|
||||
img = img.resize((new_w, new_h), Image.LANCZOS)
|
||||
|
||||
if highlights:
|
||||
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(overlay)
|
||||
@@ -111,11 +140,11 @@ def _resize_and_annotate(
|
||||
for h in highlights:
|
||||
kind = h.get("kind", "rect")
|
||||
label = h.get("label", "")
|
||||
# Highlights are in CSS px → convert to image px
|
||||
ix = h["x"] / css_scale
|
||||
iy = h["y"] / css_scale
|
||||
iw = h.get("w", 0) / css_scale
|
||||
ih = h.get("h", 0) / css_scale
|
||||
# Highlights arrive in CSS px → convert to image px.
|
||||
ix = h["x"] / css_to_image
|
||||
iy = h["y"] / css_to_image
|
||||
iw = h.get("w", 0) / css_to_image
|
||||
ih = h.get("h", 0) / css_to_image
|
||||
|
||||
if kind == "point":
|
||||
cx, cy, r = ix, iy, 10
|
||||
@@ -135,11 +164,9 @@ def _resize_and_annotate(
|
||||
width=2,
|
||||
)
|
||||
|
||||
# Label: show image pixel position so user knows where to look
|
||||
img_coords = f"img:({round(ix)},{round(iy)})"
|
||||
display_label = f"{img_coords} {label}" if label else img_coords
|
||||
display_label = f"({round(ix)},{round(iy)}) {label}".strip()
|
||||
lx, ly = ix, max(2, iy - 16)
|
||||
lx = max(2, min(lx, width - 120))
|
||||
lx = max(2, min(lx, new_w - 120))
|
||||
bbox = draw.textbbox((lx, ly), display_label, font=font)
|
||||
pad = 3
|
||||
draw.rectangle(
|
||||
@@ -153,22 +180,50 @@ def _resize_and_annotate(
|
||||
img = img.convert("RGB")
|
||||
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format="PNG", optimize=True)
|
||||
img.save(buf, format="JPEG", quality=75, optimize=True)
|
||||
return (
|
||||
base64.b64encode(buf.getvalue()).decode(),
|
||||
round(physical_scale, 4),
|
||||
round(css_scale, 4),
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Screenshot resize/annotate FAILED — returning original image with scale=1.0. "
|
||||
"css_width=%s, dpr=%s, target_width=%d. Clicks will be misaligned.",
|
||||
"Screenshot resize/annotate FAILED — returning original image. "
|
||||
"css_width=%s, dpr=%s.",
|
||||
css_width,
|
||||
dpr,
|
||||
width,
|
||||
exc_info=True,
|
||||
)
|
||||
return data, 1.0, 1.0
|
||||
return data, 1.0
|
||||
|
||||
|
||||
async def _ensure_viewport_size(tab_id: int) -> tuple[int, int]:
|
||||
"""Return ``(cssWidth, cssHeight)`` for ``tab_id``, populating the
|
||||
cache via ``window.innerWidth`` / ``window.innerHeight`` on miss.
|
||||
|
||||
Used by click / hover / press tools to turn fractional inputs
|
||||
(0..1) into CSS px, and by rect tools to turn CSS-px rects into
|
||||
fractions. Degrades to ``(1, 1)`` if the bridge can't be queried
|
||||
— that makes every coord an identity op, which is a safe no-op
|
||||
(and preferable to crashing).
|
||||
"""
|
||||
cached = _viewport_sizes.get(tab_id)
|
||||
if cached is not None and cached[0] > 0 and cached[1] > 0:
|
||||
return cached
|
||||
bridge = get_bridge()
|
||||
try:
|
||||
result = await bridge.evaluate(tab_id, "({w: window.innerWidth, h: window.innerHeight})")
|
||||
inner = (result or {}).get("result") or {}
|
||||
cw = int(float(inner.get("w") or 0))
|
||||
ch = int(float(inner.get("h") or 0))
|
||||
except Exception:
|
||||
cw, ch = 0, 0
|
||||
if cw <= 0 or ch <= 0:
|
||||
# Degraded: bridge didn't return viewport. Cache an identity
|
||||
# so we don't retry on every call; corrects itself after the
|
||||
# next successful browser_screenshot.
|
||||
cw, ch = 1, 1
|
||||
_viewport_sizes[tab_id] = (cw, ch)
|
||||
return cw, ch
|
||||
|
||||
|
||||
def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
@@ -180,29 +235,33 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
profile: str | None = None,
|
||||
full_page: bool = False,
|
||||
selector: str | None = None,
|
||||
image_type: Literal["png", "jpeg"] = "png",
|
||||
annotate: bool = True,
|
||||
width: int = _SCREENSHOT_WIDTH,
|
||||
) -> list:
|
||||
"""
|
||||
Take a screenshot of the current page.
|
||||
|
||||
Returns a normalized image alongside text metadata (URL, size, scale
|
||||
factors, etc.). Automatically annotates the last interaction (click,
|
||||
hover, type) with a bounding box overlay.
|
||||
Image is 800 px wide (JPEG quality 75, ~50–120 KB). All
|
||||
coordinate tools work in **fractions of the viewport (0..1)**,
|
||||
not pixels — so read a target's proportional position off this
|
||||
image ("~35 % from the left, ~20 % from the top") and pass
|
||||
``(0.35, 0.20)`` to ``browser_click_coordinate`` /
|
||||
``browser_hover_coordinate`` / ``browser_press_at``.
|
||||
``browser_get_rect`` and ``browser_shadow_query`` likewise
|
||||
return coordinates as fractions.
|
||||
|
||||
Args:
|
||||
tab_id: Chrome tab ID (default: active tab)
|
||||
profile: Browser profile name (default: "default")
|
||||
full_page: Capture full scrollable page (default: False)
|
||||
full_page: Capture full scrollable page (default: False).
|
||||
Note: full_page images extend beyond the viewport, so
|
||||
fractions read off them do NOT map cleanly to
|
||||
viewport-space clicks. Use for reading / overview only,
|
||||
not for pointing.
|
||||
selector: CSS selector to screenshot a specific element (optional)
|
||||
image_type: Image format - png or jpeg (default: png)
|
||||
annotate: Draw bounding box of last interaction on image (default: True)
|
||||
width: Output image width in pixels (default: 600). Use 800+ for fine
|
||||
text, 400 for quick layout checks.
|
||||
|
||||
Returns:
|
||||
List of content blocks: text metadata + image
|
||||
List of content blocks: text metadata + image.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
params = {
|
||||
@@ -252,7 +311,6 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
return [TextContent(type="text", text=json.dumps(screenshot_result))]
|
||||
|
||||
data = screenshot_result.get("data")
|
||||
mime_type = screenshot_result.get("mimeType", "image/png")
|
||||
css_width = screenshot_result.get("cssWidth", 0)
|
||||
dpr = screenshot_result.get("devicePixelRatio", 1.0)
|
||||
|
||||
@@ -263,45 +321,50 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
if annotate and target_tab in _interaction_highlights:
|
||||
highlights = [_interaction_highlights[target_tab]]
|
||||
|
||||
# Normalize to 800px wide and annotate. Offloaded to a
|
||||
# thread because PIL Image.open/resize/ImageDraw/composite on
|
||||
# a 2-megapixel PNG blocks for ~150-300ms of CPU — plenty to
|
||||
# freeze the asyncio event loop and delay every concurrent
|
||||
# tool call during a screenshot. The function is reentrant
|
||||
# (fresh PIL Image per call, no shared state), so to_thread
|
||||
# is safe.
|
||||
data, physical_scale, css_scale = await asyncio.to_thread(
|
||||
# Resize to CSS-viewport dimensions (image px == CSS px)
|
||||
# and re-encode as JPEG. Offloaded to a thread because PIL
|
||||
# Image.open/resize/ImageDraw/composite on a 2-megapixel
|
||||
# PNG blocks for ~150–300 ms of CPU — plenty to freeze the
|
||||
# asyncio event loop. Reentrant: no shared state.
|
||||
data, physical_scale = await asyncio.to_thread(
|
||||
_resize_and_annotate,
|
||||
data,
|
||||
css_width,
|
||||
dpr,
|
||||
highlights,
|
||||
width,
|
||||
)
|
||||
_screenshot_scales[target_tab] = physical_scale
|
||||
_screenshot_css_scales[target_tab] = css_scale
|
||||
# Cache live viewport dimensions so click / hover / press /
|
||||
# rect tools can translate fractions ↔ CSS px without
|
||||
# asking the page again.
|
||||
css_height = int(screenshot_result.get("cssHeight", 0)) or 0
|
||||
if target_tab is not None and css_width > 0 and css_height > 0:
|
||||
_viewport_sizes[target_tab] = (int(css_width), css_height)
|
||||
_screenshot_scales[target_tab] = physical_scale
|
||||
|
||||
meta = json.dumps(
|
||||
{
|
||||
"ok": True,
|
||||
"tabId": target_tab,
|
||||
"url": screenshot_result.get("url", ""),
|
||||
"imageType": mime_type.split("/")[-1],
|
||||
"imageType": "jpeg",
|
||||
"size": len(base64.b64decode(data)) if data else 0,
|
||||
"imageWidth": width,
|
||||
"imageWidth": _SCREENSHOT_WIDTH,
|
||||
"cssWidth": css_width,
|
||||
"cssHeight": css_height,
|
||||
"fullPage": full_page,
|
||||
"devicePixelRatio": dpr,
|
||||
"physicalScale": physical_scale,
|
||||
"cssScale": css_scale,
|
||||
"annotated": bool(highlights),
|
||||
"scaleHint": (
|
||||
f"image_coord × {css_scale} = CSS px "
|
||||
f"→ feed to browser_click_coordinate, "
|
||||
f"browser_hover_coordinate, browser_press_at "
|
||||
f"(CDP Input events use CSS pixels). "
|
||||
f"image_coord × {physical_scale} = physical px "
|
||||
f"is debug-only on HiDPI displays and must NOT "
|
||||
f"be used for clicks — it overshoots by DPR×."
|
||||
"Coordinates for click / hover / press are "
|
||||
"fractions 0..1 of the viewport. Read a "
|
||||
"target's proportional position off this image "
|
||||
"(e.g. '~35 % from the left, ~20 % from the top' "
|
||||
"→ (0.35, 0.20)) and pass that to "
|
||||
"browser_click_coordinate / "
|
||||
"browser_hover_coordinate / browser_press_at. "
|
||||
"browser_get_rect / browser_shadow_query / "
|
||||
"focused_element.rect return fractions too."
|
||||
),
|
||||
}
|
||||
)
|
||||
@@ -313,17 +376,17 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
"ok": True,
|
||||
"size": len(base64.b64decode(data)) if data else 0,
|
||||
"url": screenshot_result.get("url", ""),
|
||||
"cssWidth": css_width,
|
||||
"cssHeight": css_height,
|
||||
"physicalScale": physical_scale,
|
||||
"cssScale": css_scale,
|
||||
"debug_cssWidth": css_width,
|
||||
"debug_dpr": dpr,
|
||||
"dpr": dpr,
|
||||
},
|
||||
duration_ms=(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
|
||||
return [
|
||||
TextContent(type="text", text=meta),
|
||||
ImageContent(type="image", data=data, mimeType=mime_type),
|
||||
ImageContent(type="image", data=data, mimeType="image/jpeg"),
|
||||
]
|
||||
except Exception as e:
|
||||
log_tool_call(
|
||||
@@ -334,73 +397,6 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
)
|
||||
return [TextContent(type="text", text=json.dumps({"ok": False, "error": str(e)}))]
|
||||
|
||||
@mcp.tool()
|
||||
def browser_coords(
|
||||
x: float,
|
||||
y: float,
|
||||
tab_id: int | None = None,
|
||||
profile: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Convert screenshot image coordinates to browser click coordinates.
|
||||
|
||||
After browser_screenshot returns a downscaled image, use this to
|
||||
translate pixel positions you see in the image into the CSS pixel
|
||||
coordinates that Chrome DevTools Protocol expects.
|
||||
|
||||
**CDP Input.dispatchMouseEvent uses CSS pixels**, so you want
|
||||
``css_x`` / ``css_y`` for every click/hover tool. ``physical_x/y``
|
||||
is kept in the return for debugging on HiDPI displays — do NOT
|
||||
feed it to clicks; on a DPR=2 screen it lands 2× too far.
|
||||
|
||||
Edge case: pages using ``zoom`` or ``transform: scale()`` (e.g.
|
||||
LinkedIn's ``#interop-outlet`` shadow DOM) render in a scaled
|
||||
local coordinate space. For those, ``getBoundingClientRect()``
|
||||
reports pre-zoom coordinates and you may still need to multiply
|
||||
by the element's effective zoom. Use browser_shadow_query to
|
||||
get the zoomed rect directly.
|
||||
|
||||
Args:
|
||||
x: X pixel position in the screenshot image
|
||||
y: Y pixel position in the screenshot image
|
||||
tab_id: Chrome tab ID (default: active tab for profile)
|
||||
profile: Browser profile name (default: "default")
|
||||
|
||||
Returns:
|
||||
Dict with css_x, css_y (primary — use these), physical_x,
|
||||
physical_y (debug only), and scale factors.
|
||||
"""
|
||||
ctx = _get_context(profile)
|
||||
target_tab = tab_id or (ctx.get("activeTabId") if ctx else None)
|
||||
|
||||
physical_scale = _screenshot_scales.get(target_tab, 1.0) if target_tab else 1.0
|
||||
# css_scale stored in second slot via _screenshot_css_scales
|
||||
css_scale = _screenshot_css_scales.get(target_tab, physical_scale) if target_tab else physical_scale
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
# Primary output: CSS pixels. Feed these to click/hover/press.
|
||||
"css_x": round(x * css_scale, 1),
|
||||
"css_y": round(y * css_scale, 1),
|
||||
# Debug output: raw physical pixels. DO NOT feed to clicks on
|
||||
# HiDPI displays — CDP Input events use CSS pixels, so sending
|
||||
# physical coordinates lands the click at roughly DPR× the
|
||||
# intended position.
|
||||
"physical_x": round(x * physical_scale, 1),
|
||||
"physical_y": round(y * physical_scale, 1),
|
||||
"physicalScale": physical_scale,
|
||||
"cssScale": css_scale,
|
||||
"tabId": target_tab,
|
||||
"note": (
|
||||
"Use css_x/css_y with browser_click_coordinate, "
|
||||
"browser_hover_coordinate, browser_press_at — "
|
||||
"Chrome DevTools Protocol Input.dispatchMouseEvent "
|
||||
"operates in CSS pixels. physical_x/y is for debugging "
|
||||
"on HiDPI displays only; feeding it to clicks lands "
|
||||
"them at DPR× the intended coordinate."
|
||||
),
|
||||
}
|
||||
|
||||
@mcp.tool()
|
||||
async def browser_shadow_query(
|
||||
selector: str,
|
||||
@@ -412,7 +408,9 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
|
||||
Traverses shadow roots to find elements inside closed/open shadow DOM,
|
||||
overlays, and virtual-rendered components (e.g. LinkedIn's #interop-outlet).
|
||||
Returns getBoundingClientRect in both CSS and physical pixels.
|
||||
Returns the element's bounding rect as **fractions of the
|
||||
viewport (0..1)** — feed ``rect.cx`` / ``rect.cy`` straight
|
||||
into browser_click_coordinate / hover_coordinate / press_at.
|
||||
|
||||
Args:
|
||||
selector: CSS selectors joined by ' >>> ' to pierce shadow roots.
|
||||
@@ -421,7 +419,8 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
profile: Browser profile name (default: "default")
|
||||
|
||||
Returns:
|
||||
Dict with rect (CSS px) and physical rect (CSS px × DPR) of the element
|
||||
Dict with ``rect`` block (x, y, w, h, cx, cy) as fractions,
|
||||
plus ``cssWidth`` / ``cssHeight`` for reference.
|
||||
"""
|
||||
bridge = get_bridge()
|
||||
if not bridge or not bridge.is_connected:
|
||||
@@ -438,36 +437,27 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
return result
|
||||
|
||||
rect = result["rect"]
|
||||
physical_scale = _screenshot_scales.get(target_tab, 1.0)
|
||||
css_scale = _screenshot_css_scales.get(target_tab, 1.0)
|
||||
dpr = physical_scale / css_scale if css_scale else 1.0
|
||||
|
||||
cw, ch = await _ensure_viewport_size(target_tab)
|
||||
cw_f = float(cw) if cw > 0 else 1.0
|
||||
ch_f = float(ch) if ch > 0 else 1.0
|
||||
return {
|
||||
"ok": True,
|
||||
"selector": selector,
|
||||
"tag": rect.get("tag"),
|
||||
"css": {
|
||||
"x": rect["x"],
|
||||
"y": rect["y"],
|
||||
"w": rect["w"],
|
||||
"h": rect["h"],
|
||||
"cx": rect["cx"],
|
||||
"cy": rect["cy"],
|
||||
},
|
||||
"physical": {
|
||||
"x": round(rect["x"] * dpr, 1),
|
||||
"y": round(rect["y"] * dpr, 1),
|
||||
"w": round(rect["w"] * dpr, 1),
|
||||
"h": round(rect["h"] * dpr, 1),
|
||||
"cx": round(rect["cx"] * dpr, 1),
|
||||
"cy": round(rect["cy"] * dpr, 1),
|
||||
"rect": {
|
||||
"x": round(rect["x"] / cw_f, 4),
|
||||
"y": round(rect["y"] / ch_f, 4),
|
||||
"w": round(rect["w"] / cw_f, 4),
|
||||
"h": round(rect["h"] / ch_f, 4),
|
||||
"cx": round(rect["cx"] / cw_f, 4),
|
||||
"cy": round(rect["cy"] / ch_f, 4),
|
||||
},
|
||||
"cssWidth": cw,
|
||||
"cssHeight": ch,
|
||||
"note": (
|
||||
"Use css.cx/cy with browser_click_coordinate, "
|
||||
"browser_hover_coordinate, browser_press_at — "
|
||||
"CDP Input events operate in CSS pixels. "
|
||||
"physical.* is debug-only; feeding it to clicks "
|
||||
"lands them DPR× too far on HiDPI displays."
|
||||
"rect fields are fractions of the viewport (0..1). "
|
||||
"Pass rect.cx / rect.cy to browser_click_coordinate / "
|
||||
"hover_coordinate / press_at."
|
||||
),
|
||||
}
|
||||
|
||||
@@ -480,11 +470,10 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
"""
|
||||
Get the bounding rect of an element by CSS selector.
|
||||
|
||||
Supports '>>>' shadow-piercing selectors for overlay/shadow DOM content.
|
||||
Returns coordinates in CSS pixels (for clicks and DOM APIs); the
|
||||
physical-pixel variant is returned for debugging on HiDPI displays
|
||||
only — it must not be fed to click/hover/press tools, which use
|
||||
CSS pixels.
|
||||
Supports '>>>' shadow-piercing selectors for overlay/shadow DOM
|
||||
content. Returns the rect as **fractions of the viewport
|
||||
(0..1)** — the same coordinate space browser_click_coordinate
|
||||
/ hover_coordinate / press_at expect.
|
||||
|
||||
Args:
|
||||
selector: CSS selector, optionally with ' >>> ' to pierce shadow roots.
|
||||
@@ -493,7 +482,8 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
profile: Browser profile name (default: "default")
|
||||
|
||||
Returns:
|
||||
Dict with css and physical bounding rects
|
||||
Dict with ``rect`` block (x, y, w, h, cx, cy) as fractions,
|
||||
plus ``cssWidth`` / ``cssHeight`` for reference.
|
||||
"""
|
||||
bridge = get_bridge()
|
||||
if not bridge or not bridge.is_connected:
|
||||
@@ -510,36 +500,27 @@ def register_inspection_tools(mcp: FastMCP) -> None:
|
||||
return result
|
||||
|
||||
rect = result["rect"]
|
||||
physical_scale = _screenshot_scales.get(target_tab, 1.0)
|
||||
css_scale = _screenshot_css_scales.get(target_tab, 1.0)
|
||||
dpr = physical_scale / css_scale if css_scale else 1.0
|
||||
|
||||
cw, ch = await _ensure_viewport_size(target_tab)
|
||||
cw_f = float(cw) if cw > 0 else 1.0
|
||||
ch_f = float(ch) if ch > 0 else 1.0
|
||||
return {
|
||||
"ok": True,
|
||||
"selector": selector,
|
||||
"tag": rect.get("tag"),
|
||||
"css": {
|
||||
"x": rect["x"],
|
||||
"y": rect["y"],
|
||||
"w": rect["w"],
|
||||
"h": rect["h"],
|
||||
"cx": rect["cx"],
|
||||
"cy": rect["cy"],
|
||||
},
|
||||
"physical": {
|
||||
"x": round(rect["x"] * dpr, 1),
|
||||
"y": round(rect["y"] * dpr, 1),
|
||||
"w": round(rect["w"] * dpr, 1),
|
||||
"h": round(rect["h"] * dpr, 1),
|
||||
"cx": round(rect["cx"] * dpr, 1),
|
||||
"cy": round(rect["cy"] * dpr, 1),
|
||||
"rect": {
|
||||
"x": round(rect["x"] / cw_f, 4),
|
||||
"y": round(rect["y"] / ch_f, 4),
|
||||
"w": round(rect["w"] / cw_f, 4),
|
||||
"h": round(rect["h"] / ch_f, 4),
|
||||
"cx": round(rect["cx"] / cw_f, 4),
|
||||
"cy": round(rect["cy"] / ch_f, 4),
|
||||
},
|
||||
"cssWidth": cw,
|
||||
"cssHeight": ch,
|
||||
"note": (
|
||||
"Use css.cx/cy with browser_click_coordinate, "
|
||||
"browser_hover_coordinate, browser_press_at — "
|
||||
"CDP Input events operate in CSS pixels. "
|
||||
"physical.* is debug-only; feeding it to clicks "
|
||||
"lands them DPR× too far on HiDPI displays."
|
||||
"rect fields are fractions of the viewport (0..1). "
|
||||
"Pass rect.cx / rect.cy to browser_click_coordinate / "
|
||||
"hover_coordinate / press_at."
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -108,24 +108,31 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
button: Literal["left", "right", "middle"] = "left",
|
||||
) -> dict:
|
||||
"""
|
||||
Click at specific viewport coordinates (CSS pixels).
|
||||
Click at a FRACTION of the viewport (0..1, 0..1).
|
||||
|
||||
Chrome DevTools Protocol's Input.dispatchMouseEvent operates in
|
||||
**CSS pixels**, not physical pixels. If you have a screenshot
|
||||
image coordinate, convert it with ``browser_coords(x, y)`` and
|
||||
use the returned ``css_x`` / ``css_y`` — not ``physical_x/y``.
|
||||
On a DPR=2 display, feeding physical coordinates lands the click
|
||||
at 2× the intended position.
|
||||
Coordinates are **fractions of the viewport**, not pixels:
|
||||
``(0.5, 0.5)`` is the center, ``(0.1, 0.2)`` is 10 % from the
|
||||
left and 20 % from the top. Read a target's proportional
|
||||
position off ``browser_screenshot`` (or pass
|
||||
``rect.cx`` / ``rect.cy`` from ``browser_get_rect`` /
|
||||
``browser_shadow_query`` directly — they return fractions too).
|
||||
|
||||
Fractions are used because every vision model resizes or tiles
|
||||
images differently (Claude ~1.15 MP target, GPT-4o 512-px
|
||||
tiles, etc.). Proportional positions survive every such
|
||||
transform; pixel coords do not.
|
||||
|
||||
Args:
|
||||
x: X coordinate in CSS pixels (viewport space)
|
||||
y: Y coordinate in CSS pixels (viewport space)
|
||||
x: X fraction of the viewport (0..1).
|
||||
y: Y fraction of the viewport (0..1).
|
||||
tab_id: Chrome tab ID (default: active tab)
|
||||
profile: Browser profile name (default: "default")
|
||||
button: Mouse button to click (left, right, middle)
|
||||
|
||||
Returns:
|
||||
Dict with click result
|
||||
Dict with click result, including ``focused_element``
|
||||
describing what the click focused. ``focused_element.rect``
|
||||
is also in fractions.
|
||||
"""
|
||||
start = time.perf_counter()
|
||||
params = {"x": x, "y": y, "tab_id": tab_id, "profile": profile, "button": button}
|
||||
@@ -148,18 +155,33 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
log_tool_call("browser_click_coordinate", params, result=result)
|
||||
return result
|
||||
|
||||
try:
|
||||
from .inspection import _screenshot_css_scales, _screenshot_scales
|
||||
# Pixel-input guard: legitimate fractions live in [0, 1]. Allow a
|
||||
# small overshoot tolerance for edge targets.
|
||||
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Coords ({x}, {y}) look like pixels. This tool expects "
|
||||
"fractions 0..1 of the viewport. Read the target's "
|
||||
"proportional position off browser_screenshot, or pass "
|
||||
"rect.cx / rect.cy from browser_get_rect / "
|
||||
"browser_shadow_query (they return fractions)."
|
||||
),
|
||||
}
|
||||
log_tool_call("browser_click_coordinate", params, result=result)
|
||||
return result
|
||||
|
||||
click_result = await bridge.click_coordinate(target_tab, x, y, button=button)
|
||||
try:
|
||||
from .inspection import _ensure_viewport_size
|
||||
|
||||
cw, ch = await _ensure_viewport_size(target_tab)
|
||||
css_x = x * cw
|
||||
css_y = y * ch
|
||||
click_result = await bridge.click_coordinate(target_tab, css_x, css_y, button=button)
|
||||
log_tool_call(
|
||||
"browser_click_coordinate",
|
||||
params,
|
||||
result={
|
||||
**click_result,
|
||||
"debug_stored_physicalScale": _screenshot_scales.get(target_tab, "unset"),
|
||||
"debug_stored_cssScale": _screenshot_css_scales.get(target_tab, "unset"),
|
||||
},
|
||||
result={**click_result, "cssWidth": cw, "cssHeight": ch},
|
||||
duration_ms=(time.perf_counter() - start) * 1000,
|
||||
)
|
||||
return click_result
|
||||
@@ -484,15 +506,16 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
profile: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Hover at CSS pixel coordinates without needing a CSS selector.
|
||||
Hover at a FRACTION of the viewport (0..1, 0..1).
|
||||
|
||||
Use this instead of browser_hover when the element is in an overlay,
|
||||
shadow DOM, or virtual-rendered component that isn't in the regular DOM.
|
||||
Pair with browser_coords to convert screenshot image positions to CSS pixels.
|
||||
``x`` / ``y`` are fractions of the viewport (``0.5`` = center);
|
||||
the tool converts to CSS px internally.
|
||||
|
||||
Args:
|
||||
x: CSS pixel X coordinate
|
||||
y: CSS pixel Y coordinate
|
||||
x: X fraction of the viewport (0..1).
|
||||
y: Y fraction of the viewport (0..1).
|
||||
tab_id: Chrome tab ID (default: active tab)
|
||||
profile: Browser profile name (default: "default")
|
||||
|
||||
@@ -520,8 +543,22 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
log_tool_call("browser_hover_coordinate", params, result=result)
|
||||
return result
|
||||
|
||||
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Coords ({x}, {y}) look like pixels. This tool expects "
|
||||
"fractions 0..1 of the viewport."
|
||||
),
|
||||
}
|
||||
log_tool_call("browser_hover_coordinate", params, result=result)
|
||||
return result
|
||||
|
||||
try:
|
||||
hover_result = await bridge.hover_coordinate(target_tab, x, y)
|
||||
from .inspection import _ensure_viewport_size
|
||||
|
||||
cw, ch = await _ensure_viewport_size(target_tab)
|
||||
hover_result = await bridge.hover_coordinate(target_tab, x * cw, y * ch)
|
||||
log_tool_call(
|
||||
"browser_hover_coordinate",
|
||||
params,
|
||||
@@ -548,16 +585,17 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
profile: str | None = None,
|
||||
) -> dict:
|
||||
"""
|
||||
Move mouse to CSS pixel coordinates then press a key.
|
||||
Move mouse to a FRACTION of the viewport (0..1, 0..1), then press a key.
|
||||
|
||||
Use this instead of browser_press when the focused element is in an overlay
|
||||
or virtual-rendered component. Moving the mouse first routes the key event
|
||||
through native browser hit-testing instead of the DOM focus chain.
|
||||
Pair with browser_coords to convert screenshot image positions to CSS pixels.
|
||||
``x`` / ``y`` are fractions of the viewport; the tool converts
|
||||
to CSS px internally.
|
||||
|
||||
Args:
|
||||
x: CSS pixel X coordinate to position mouse
|
||||
y: CSS pixel Y coordinate to position mouse
|
||||
x: X fraction of the viewport (0..1).
|
||||
y: Y fraction of the viewport (0..1).
|
||||
key: Key to press (e.g. 'Enter', 'Space', 'Escape', 'ArrowDown')
|
||||
tab_id: Chrome tab ID (default: active tab)
|
||||
profile: Browser profile name (default: "default")
|
||||
@@ -586,8 +624,22 @@ def register_interaction_tools(mcp: FastMCP) -> None:
|
||||
log_tool_call("browser_press_at", params, result=result)
|
||||
return result
|
||||
|
||||
if x > 1.5 or y > 1.5 or x < -0.1 or y < -0.1:
|
||||
result = {
|
||||
"ok": False,
|
||||
"error": (
|
||||
f"Coords ({x}, {y}) look like pixels. This tool expects "
|
||||
"fractions 0..1 of the viewport."
|
||||
),
|
||||
}
|
||||
log_tool_call("browser_press_at", params, result=result)
|
||||
return result
|
||||
|
||||
try:
|
||||
press_result = await bridge.press_key_at(target_tab, x, y, key)
|
||||
from .inspection import _ensure_viewport_size
|
||||
|
||||
cw, ch = await _ensure_viewport_size(target_tab)
|
||||
press_result = await bridge.press_key_at(target_tab, x * cw, y * ch, key)
|
||||
log_tool_call(
|
||||
"browser_press_at",
|
||||
params,
|
||||
|
||||
@@ -35,6 +35,23 @@ def _resolve_profile(profile: str | None) -> str:
|
||||
_EXTENSION_PATH = (Path(__file__).parent.parent.parent.parent.parent / "browser-extension").resolve()
|
||||
|
||||
|
||||
def _clear_profile_tab_caches(ctx: dict[str, Any]) -> None:
|
||||
"""Clear per-tab caches for every tab the profile knew about.
|
||||
|
||||
Individual tab closes go through ``bridge.close_tab`` which clears
|
||||
caches per-tab; context destroys close every tab at once without
|
||||
per-tab notifications, so we clear them here from the tracked set.
|
||||
"""
|
||||
tab_ids = ctx.get("tabs") or set()
|
||||
if not tab_ids:
|
||||
return
|
||||
from ..bridge import clear_tab_highlights
|
||||
from .inspection import clear_tab_state
|
||||
|
||||
clear_tab_state(tab_ids)
|
||||
clear_tab_highlights(tab_ids)
|
||||
|
||||
|
||||
async def shutdown_all_contexts() -> None:
|
||||
"""Close all active browser contexts. Called at GCU server shutdown."""
|
||||
if not _contexts:
|
||||
@@ -42,6 +59,7 @@ async def shutdown_all_contexts() -> None:
|
||||
bridge = get_bridge()
|
||||
for profile_name, ctx in list(_contexts.items()):
|
||||
group_id = ctx.get("groupId")
|
||||
_clear_profile_tab_caches(ctx)
|
||||
if group_id is not None and bridge and bridge.is_connected:
|
||||
try:
|
||||
await bridge.destroy_context(group_id)
|
||||
@@ -232,6 +250,7 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
"groupId": group_id,
|
||||
"activeTabId": tab_id,
|
||||
"_seedTabId": tab_id, # reused by first browser_open call
|
||||
"tabs": {tab_id} if tab_id is not None else set(),
|
||||
}
|
||||
|
||||
logger.info(
|
||||
@@ -299,6 +318,9 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
|
||||
try:
|
||||
group_id = ctx.get("groupId")
|
||||
closed_tabs = 0
|
||||
# Clear per-tab caches before tearing down the group — once
|
||||
# destroyed we won't get per-tab close notifications.
|
||||
_clear_profile_tab_caches(ctx)
|
||||
if group_id is not None:
|
||||
result = await bridge.destroy_context(group_id)
|
||||
closed_tabs = result.get("closedTabs", 0)
|
||||
|
||||
@@ -134,6 +134,11 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
result = await bridge.create_tab(url=url, group_id=ctx.get("groupId"))
|
||||
tab_id = result.get("tabId")
|
||||
|
||||
# Track tab_ids so browser_stop can clear per-tab caches
|
||||
# for every tab in this profile at once.
|
||||
if tab_id is not None:
|
||||
ctx.setdefault("tabs", set()).add(tab_id)
|
||||
|
||||
# Update active tab if not background
|
||||
if not background and tab_id is not None:
|
||||
ctx["activeTabId"] = tab_id
|
||||
@@ -201,6 +206,12 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
try:
|
||||
await bridge.close_tab(target_tab)
|
||||
|
||||
# Forget the closed tab so ctx["tabs"] only reflects tabs
|
||||
# that could still get per-tab cache activity.
|
||||
tabs_set = ctx.get("tabs")
|
||||
if isinstance(tabs_set, set):
|
||||
tabs_set.discard(target_tab)
|
||||
|
||||
# Update active tab if we closed it
|
||||
if ctx.get("activeTabId") == target_tab:
|
||||
result = await bridge.list_tabs(ctx.get("groupId"))
|
||||
@@ -300,6 +311,7 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
active_tab_id = ctx.get("activeTabId")
|
||||
|
||||
closed = 0
|
||||
tabs_set = ctx.get("tabs") if isinstance(ctx.get("tabs"), set) else None
|
||||
for tab in tabs:
|
||||
tid = tab.get("id")
|
||||
if keep_active and tid == active_tab_id:
|
||||
@@ -307,6 +319,8 @@ def register_tab_tools(mcp: FastMCP) -> None:
|
||||
try:
|
||||
await bridge.close_tab(tid)
|
||||
closed += 1
|
||||
if tabs_set is not None and tid is not None:
|
||||
tabs_set.discard(tid)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -139,7 +139,10 @@ def main() -> None:
|
||||
mcp.run(transport="stdio")
|
||||
else:
|
||||
logger.info(f"Starting GCU server on {args.host}:{args.port}")
|
||||
mcp.run(transport="http", host=args.host, port=args.port)
|
||||
# FastMCP.run() forwards kwargs to anyio.run() instead of the
|
||||
# transport, which breaks host/port for SSE. Invoke run_async
|
||||
# directly so the kwargs land on run_sse_async.
|
||||
asyncio.run(mcp.run_async(transport="sse", host=args.host, port=args.port))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user