fix: proper skill loading

This commit is contained in:
Timothy
2026-04-08 11:37:29 -07:00
parent b3759db83b
commit 7daca39bb2
13 changed files with 296 additions and 152 deletions
+1 -1
View File
@@ -68,7 +68,7 @@ class LoopConfig:
max_output_value_chars: int = 2_000
# Stream retry.
max_stream_retries: int = 3
max_stream_retries: int = 5
stream_retry_backoff_base: float = 2.0
stream_retry_max_delay: float = 60.0
@@ -0,0 +1,3 @@
{
"include": ["gcu-tools"]
}
+13 -1
View File
@@ -91,7 +91,19 @@ async def select_memories(
resp.stop_reason,
)
return []
data = json.loads(raw)
# Some models wrap JSON in markdown fences or add preamble text.
# Try to extract the JSON object if raw parse fails.
try:
data = json.loads(raw)
except json.JSONDecodeError:
import re
m = re.search(r"\{.*\}", raw, re.DOTALL)
if m:
data = json.loads(m.group())
else:
logger.warning("recall: LLM returned non-JSON: %.200s", raw)
return []
selected = data.get("selected_memories", [])
valid_names = {f.filename for f in files}
result = [s for s in selected if s in valid_names][:max_results]
-18
View File
@@ -696,24 +696,6 @@ class ExecutionManager:
# the executor's session_state (memory + resume_from) carries
# forward so the next attempt resumes at the failed node.
while True:
# Run execution middleware (per-attempt, including resurrections)
if self._execution_middleware:
from framework.pipeline.execution_middleware import (
ExecutionContext as _ExecMwCtx,
)
mw_ctx = _ExecMwCtx(
execution_id=execution_id,
stream_id=self.stream_id,
run_id=ctx.run_id or "",
input_data=_current_input_data or {},
session_state=_current_session_state,
attempt=_resurrection_count + 1,
)
for mw in self._execution_middleware:
mw_ctx = await mw.on_execution_start(mw_ctx)
_current_input_data = mw_ctx.input_data
# Create executor for this execution.
executor = Orchestrator(
runtime=runtime_adapter,
+2 -1
View File
@@ -660,10 +660,11 @@ class SessionManager:
task = asyncio.create_task(
asyncio.shield(run_shutdown_reflection(session.queen_dir, session.llm)),
name=f"shutdown-reflect-{session_id}",
)
logger.info("Session '%s': shutdown reflection spawned", session_id)
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
logger.info("Session '%s': shutdown reflection spawned", session_id)
except Exception:
logger.warning(
"Session '%s': failed to spawn shutdown reflection", session_id, exc_info=True
@@ -0,0 +1,80 @@
---
name: hive.browser-automation
description: Best practices for browser automation via gcu-tools MCP server (reading pages, navigation, scrolling, tab management, shadow DOM, coordinates).
metadata:
author: hive
type: default-skill
---
## Operational Protocol: Browser Automation
Follow these rules for reliable, efficient browser interaction.
### Reading Pages
- ALWAYS prefer `browser_snapshot` over `browser_get_text("body")` -- it returns a compact ~1-5 KB accessibility tree vs 100+ KB of raw HTML.
- Interaction tools (`browser_click`, `browser_type`, `browser_fill`, `browser_scroll`, etc.) return a page snapshot automatically in their result. Use it to decide your next action -- do NOT call `browser_snapshot` separately after every action. Only call `browser_snapshot` when you need a fresh view without performing an action, or after setting `auto_snapshot=false`.
- Do NOT use `browser_screenshot` to read text -- use `browser_snapshot` for that (compact, searchable, fast).
- DO use `browser_screenshot` when you need visual context: charts, images, canvas elements, layout verification, or when the snapshot doesn't capture what you need.
- Only fall back to `browser_get_text` for extracting specific small elements by CSS selector.
### Navigation & Waiting
- `browser_navigate` and `browser_open` already wait for the page to load. Do NOT call `browser_wait` with no arguments after navigation -- it wastes time. Only use `browser_wait` when you need a *specific element* or *text* to appear (pass `selector` or `text`).
- NEVER re-navigate to the same URL after scrolling -- this resets your scroll position and loses loaded content.
### Scrolling
- Use large scroll amounts ~2000 when loading more content -- sites like twitter and linkedin have lazy loading for paging.
- The scroll result includes a snapshot automatically -- no need to call `browser_snapshot` separately.
### Batching Actions
- You can call multiple tools in a single turn -- they execute in parallel. ALWAYS batch independent actions together. Examples: fill multiple form fields in one turn, navigate + snapshot in one turn, click + scroll if targeting different elements.
- When batching, set `auto_snapshot=false` on all but the last action to avoid redundant snapshots.
- Aim for 3-5 tool calls per turn minimum. One tool call per turn is wasteful.
### Error Recovery
- If a tool fails, retry once with the same approach.
- If it fails a second time, STOP retrying and switch approach.
- If `browser_snapshot` fails, try `browser_get_text` with a specific small selector as fallback.
- If `browser_open` fails or page seems stale, `browser_stop`, then `browser_start`, then retry.
### Tab Management
**Close tabs as soon as you are done with them** -- not only at the end of the task. After reading or extracting data from a tab, close it immediately.
- Finished reading/extracting from a tab? `browser_close(target_id=...)`
- Completed a multi-tab workflow? `browser_close_finished()` to clean up all your tabs
- More than 3 tabs open? Stop and close finished ones before opening more
- Popup appeared that you didn't need? Close it immediately
`browser_tabs` returns an `origin` field for each tab:
- `"agent"` -- you opened it; you own it; close it when done
- `"popup"` -- opened by a link or script; close after extracting what you need
- `"startup"` or `"user"` -- leave these alone unless the task requires it
Never accumulate tabs. Treat every tab you open as a resource you must free.
### Shadow DOM & Overlays
Some sites (LinkedIn messaging, etc.) render content inside closed shadow roots invisible to regular DOM queries.
- `browser_shadow_query("#interop-outlet >>> #msg-overlay >>> p")` -- uses `>>>` to pierce shadow roots. Returns `rect` in CSS pixels and `physicalRect` ready for coordinate tools.
- `browser_get_rect(selector="...", pierce_shadow=true)` -- get physical rect for any element including shadow DOM.
### Coordinate System
There are THREE coordinate spaces. Using the wrong one causes clicks/hovers to land in the wrong place.
| Space | Used by | How to get |
|---|---|---|
| Physical pixels | `browser_click_coordinate` | `browser_coords` `physical_x/y` |
| CSS pixels | `getBoundingClientRect()`, `elementFromPoint` | `browser_coords` `css_x/y` |
| Screenshot pixels | What you see in the image | Raw position in screenshot |
**Converting screenshot to physical**: `browser_coords(x, y)` then use `physical_x/y`.
**Converting CSS to physical**: multiply by `window.devicePixelRatio` (typically 1.6 on HiDPI).
**Never** pass raw `getBoundingClientRect()` values to coordinate tools without multiplying by DPR first.
### Login & Auth Walls
- If you see a "Log in" or "Sign up" prompt, report the auth wall immediately -- do NOT attempt to log in.
- Check for cookie consent banners and dismiss them if they block content.
### Efficiency
- Minimize tool calls -- combine actions where possible.
- When a snapshot result is saved to a spillover file, use `run_command` with grep to extract specific data rather than re-reading the full file.
- Call `set_output` in the same turn as your last browser action when possible -- don't waste a turn.
+4 -5
View File
@@ -64,15 +64,14 @@ class SkillCatalog:
Returns empty string if no community/user skills are discovered
(default skills are handled separately by DefaultSkillManager).
"""
# Filter out framework-scope skills (default skills) — they're
# injected via the protocols prompt, not the catalog
community_skills = [s for s in self._skills.values() if s.source_scope != "framework"]
# All skills go through the catalog for progressive disclosure.
all_skills = list(self._skills.values())
if not community_skills:
if not all_skills:
return ""
lines = ["<available_skills>"]
for skill in sorted(community_skills, key=lambda s: s.name):
for skill in sorted(all_skills, key=lambda s: s.name):
lines.append(" <skill>")
lines.append(f" <name>{escape(skill.name)}</name>")
lines.append(f" <description>{escape(skill.description)}</description>")
+27 -38
View File
@@ -120,61 +120,50 @@ class SkillsManager:
skills_config = self._config.skills_config
# 1. Community skill discovery (when project_root is available)
catalog_prompt = ""
# 1. Skill discovery -- always run to pick up framework skills;
# community/project skills only when project_root is available.
discovery = SkillDiscovery(DiscoveryConfig(
project_root=self._config.project_root,
skip_framework_scope=False,
))
discovered = discovery.discover()
self._watched_dirs = discovery.scanned_directories
# Trust-gate project-scope skills (AS-13)
if self._config.project_root is not None and not self._config.skip_community_discovery:
from framework.skills.trust import TrustGate
discovery = SkillDiscovery(DiscoveryConfig(project_root=self._config.project_root))
discovered = discovery.discover()
self._watched_dirs = discovery.scanned_directories
# Trust-gate project-scope skills (AS-13)
discovered = TrustGate(interactive=self._config.interactive).filter_and_gate(
discovered, project_dir=self._config.project_root
)
catalog = SkillCatalog(discovered)
self._allowlisted_dirs = catalog.allowlisted_dirs
catalog_prompt = catalog.to_prompt()
catalog = SkillCatalog(discovered)
self._allowlisted_dirs = catalog.allowlisted_dirs
catalog_prompt = catalog.to_prompt()
# Pre-activated community skills
if skills_config.skills:
pre_activated = catalog.build_pre_activated_prompt(skills_config.skills)
if pre_activated:
if catalog_prompt:
catalog_prompt = f"{catalog_prompt}\n\n{pre_activated}"
else:
catalog_prompt = pre_activated
# Pre-activated community skills
if skills_config.skills:
pre_activated = catalog.build_pre_activated_prompt(skills_config.skills)
if pre_activated:
if catalog_prompt:
catalog_prompt = f"{catalog_prompt}\n\n{pre_activated}"
else:
catalog_prompt = pre_activated
# 2. Default skills (always loaded unless explicitly disabled)
# 2. Default skills -- discovered via _default_skills/ and included
# in the catalog for progressive disclosure (no longer force-injected
# as protocols_prompt). DefaultSkillManager still handles config,
# logging, and metadata.
default_mgr = DefaultSkillManager(config=skills_config)
default_mgr.load()
default_mgr.log_active_skills()
protocols_prompt = default_mgr.build_protocols_prompt()
self._default_mgr = default_mgr
# DX-3: Community skill startup summary
if self._config.project_root is not None and not self._config.skip_community_discovery:
community_count = len(catalog._skills) if catalog_prompt else 0
pre_activated_count = len(skills_config.skills) if skills_config.skills else 0
logger.info(
"Skills: %d community (%d catalog, %d pre-activated)",
community_count,
community_count,
pre_activated_count,
)
# 3. Cache
self._catalog_prompt = catalog_prompt
self._protocols_prompt = protocols_prompt
self._protocols_prompt = "" # all skills use progressive disclosure now
if protocols_prompt:
logger.info(
"Skill system ready: protocols=%d chars, catalog=%d chars",
len(protocols_prompt),
len(catalog_prompt),
)
else:
if catalog_prompt:
logger.warning("Skill system produced empty protocols_prompt")
# ------------------------------------------------------------------
+24 -41
View File
@@ -9,51 +9,34 @@
const HIVE_WS_URL = "ws://127.0.0.1:9229/bridge";
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_DELAY = 10000; // Max 10 seconds between attempts
const RETRY_INTERVAL = 2000; // Poll every 2s while disconnected
function connect() {
// Exponential backoff with cap
const delay = Math.min(reconnectAttempts * 1000, MAX_RECONNECT_DELAY);
try {
ws = new WebSocket(HIVE_WS_URL);
if (reconnectAttempts > 0) {
console.log(`[Beeline] Reconnecting in ${delay}ms (attempt ${reconnectAttempts + 1})...`);
ws.onopen = () => {
console.log("[Beeline] WebSocket connected to Hive");
chrome.runtime.sendMessage({ _beeline: true, type: "ws_open" });
};
ws.onmessage = (event) => {
chrome.runtime.sendMessage({ _beeline: true, type: "ws_message", data: event.data });
};
ws.onclose = (event) => {
console.log(`[Beeline] WebSocket closed: code=${event.code}, reason=${event.reason}`);
chrome.runtime.sendMessage({ _beeline: true, type: "ws_close" });
setTimeout(connect, RETRY_INTERVAL);
};
ws.onerror = () => {
console.warn(`[Beeline] WebSocket connection failed (server may not be running)`);
};
} catch (error) {
console.error("[Beeline] Failed to create WebSocket:", error.message);
setTimeout(connect, RETRY_INTERVAL);
}
setTimeout(() => {
try {
ws = new WebSocket(HIVE_WS_URL);
ws.onopen = () => {
console.log("[Beeline] WebSocket connected to Hive");
reconnectAttempts = 0;
chrome.runtime.sendMessage({ _beeline: true, type: "ws_open" });
};
ws.onmessage = (event) => {
chrome.runtime.sendMessage({ _beeline: true, type: "ws_message", data: event.data });
};
ws.onclose = (event) => {
console.log(`[Beeline] WebSocket closed: code=${event.code}, reason=${event.reason}`);
chrome.runtime.sendMessage({ _beeline: true, type: "ws_close" });
reconnectAttempts++;
// Reconnect after delay
setTimeout(connect, 2000);
};
ws.onerror = () => {
// Don't log the full error object - it's usually just an Event
// The actual error will be reflected in onclose
console.warn(`[Beeline] WebSocket connection failed (server may not be running)`);
// Don't close here - let onclose handle cleanup
};
} catch (error) {
console.error("[Beeline] Failed to create WebSocket:", error.message);
reconnectAttempts++;
setTimeout(connect, 2000);
}
}, delay);
}
// Forward outbound messages from the service worker onto the WebSocket.
+112 -44
View File
@@ -1026,6 +1026,9 @@ class BeelineBridge:
await self.highlight_point(tab_id, x, y, label=f"{key} ({x},{y})")
return {"ok": True, "action": "press_at", "x": x, "y": y, "key": key}
# Duration (ms) that injected highlights stay visible before fading out.
_HIGHLIGHT_DURATION_MS = 1500
async def highlight_rect(
self,
tab_id: int,
@@ -1036,61 +1039,112 @@ class BeelineBridge:
label: str = "",
color: dict | None = None,
) -> None:
"""Draw a CDP Overlay highlight box in the live browser window.
"""Inject a visible highlight overlay into the page DOM.
Visible in the next screenshot. Automatically cleared on the next
interaction or by calling clear_highlight().
Creates a fixed-position div with border, background tint, and an
optional label tag. The element fades out after ``_HIGHLIGHT_DURATION_MS``
and removes itself. Much more visible than the CDP Overlay API.
"""
await self.cdp_attach(tab_id)
await self._try_enable_domain(tab_id, "Overlay")
fill = color or {"r": 59, "g": 130, "b": 246, "a": 0.35} # blue-500 @ 35%
outline = {"r": fill["r"], "g": fill["g"], "b": fill["b"], "a": 1.0}
await self._cdp(
tab_id,
"Overlay.highlightRect",
{
"x": int(x),
"y": int(y),
"width": max(1, int(w)),
"height": max(1, int(h)),
"color": fill,
"outlineColor": outline,
},
)
fill = color or {"r": 59, "g": 130, "b": 246, "a": 0.18}
border_rgb = f"rgb({fill['r']},{fill['g']},{fill['b']})"
bg_rgba = f"rgba({fill['r']},{fill['g']},{fill['b']},{fill.get('a', 0.18)})"
duration = self._HIGHLIGHT_DURATION_MS
# Escape label for safe injection
safe_label = json.dumps(label[:60]) if label else '""'
js = f"""
(function() {{
// Remove any previous hive highlight
var old = document.getElementById('__hive_hl');
if (old) old.remove();
var box = document.createElement('div');
box.id = '__hive_hl';
box.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;'
+ 'left:{int(x)}px;top:{int(y)}px;width:{max(1,int(w))}px;height:{max(1,int(h))}px;'
+ 'border:2px solid {border_rgb};background:{bg_rgba};'
+ 'border-radius:3px;transition:opacity 0.4s ease;opacity:1;'
+ 'box-shadow:0 0 8px {bg_rgba};';
var lbl = {safe_label};
if (lbl) {{
var tag = document.createElement('span');
tag.textContent = lbl;
tag.style.cssText = 'position:absolute;left:0;top:-20px;'
+ 'background:{border_rgb};color:#fff;font:bold 11px/16px system-ui;'
+ 'padding:1px 6px;border-radius:3px;white-space:nowrap;max-width:200px;'
+ 'overflow:hidden;text-overflow:ellipsis;';
box.appendChild(tag);
}}
document.documentElement.appendChild(box);
setTimeout(function() {{ box.style.opacity = '0'; }}, {duration});
setTimeout(function() {{ box.remove(); }}, {duration + 500});
}})();
"""
try:
await self.cdp_attach(tab_id)
await self.evaluate(tab_id, js)
except Exception:
pass # best-effort visual feedback
_interaction_highlights[tab_id] = {
"x": x,
"y": y,
"w": w,
"h": h,
"label": label,
"kind": "rect",
"x": x, "y": y, "w": w, "h": h,
"label": label, "kind": "rect",
}
async def highlight_point(self, tab_id: int, x: float, y: float, label: str = "") -> None:
"""Highlight a coordinate as a small crosshair box in the browser."""
r = 12 # half-size of the crosshair box in CSS px
await self.highlight_rect(
tab_id,
x - r,
y - r,
r * 2,
r * 2,
label=label,
color={"r": 239, "g": 68, "b": 68, "a": 0.45}, # red-500 @ 45%
)
"""Highlight a coordinate with a pulsing dot and crosshair."""
duration = self._HIGHLIGHT_DURATION_MS
safe_label = json.dumps(label[:60]) if label else '""'
js = f"""
(function() {{
var old = document.getElementById('__hive_hl');
if (old) old.remove();
var dot = document.createElement('div');
dot.id = '__hive_hl';
dot.style.cssText = 'position:fixed;z-index:2147483647;pointer-events:none;'
+ 'left:{int(x)-8}px;top:{int(y)-8}px;width:16px;height:16px;'
+ 'border-radius:50%;background:rgba(239,68,68,0.7);'
+ 'box-shadow:0 0 0 4px rgba(239,68,68,0.25),0 0 12px rgba(239,68,68,0.4);'
+ 'transition:opacity 0.4s ease;opacity:1;';
var lbl = {safe_label};
if (lbl) {{
var tag = document.createElement('span');
tag.textContent = lbl;
tag.style.cssText = 'position:absolute;left:20px;top:-4px;'
+ 'background:rgba(239,68,68,0.9);color:#fff;font:bold 11px/16px system-ui;'
+ 'padding:1px 6px;border-radius:3px;white-space:nowrap;';
dot.appendChild(tag);
}}
document.documentElement.appendChild(dot);
setTimeout(function() {{ dot.style.opacity = '0'; }}, {duration});
setTimeout(function() {{ dot.remove(); }}, {duration + 500});
}})();
"""
try:
await self.cdp_attach(tab_id)
await self.evaluate(tab_id, js)
except Exception:
pass
_interaction_highlights[tab_id] = {
"x": x,
"y": y,
"w": 0,
"h": 0,
"label": label,
"kind": "point",
"x": x, "y": y, "w": 0, "h": 0,
"label": label, "kind": "point",
}
async def clear_highlight(self, tab_id: int) -> None:
"""Remove the CDP Overlay highlight from the browser."""
"""Remove the injected highlight from the page."""
try:
await self._cdp(tab_id, "Overlay.hideHighlight")
await self.evaluate(tab_id, """
var el = document.getElementById('__hive_hl');
if (el) el.remove();
""")
except Exception:
pass
_interaction_highlights.pop(tab_id, None)
@@ -1199,6 +1253,20 @@ class BeelineBridge:
},
)
# Highlight the select element
rect_result = await self.evaluate(
tab_id,
f"(function(){{const el=document.querySelector("
f"{json.dumps(selector)});if(!el)return null;"
f"const r=el.getBoundingClientRect();"
f"return{{x:r.left,y:r.top,w:r.width,h:r.height}};}})()",
)
rect = (rect_result or {}).get("result")
if rect:
await self.highlight_rect(
tab_id, rect["x"], rect["y"], rect["w"], rect["h"], label=selector
)
return {"ok": True, "action": "select", "selector": selector, "selected": values}
# ── Inspection ─────────────────────────────────────────────────────────────
+22
View File
@@ -113,6 +113,28 @@ def register_advanced_tools(mcp: FastMCP) -> None:
return {"ok": False, "error": "No active tab"}
try:
# Show a brief toast in the browser so the user sees JS executing
snippet = script.strip().replace("'", "\\'")[:80]
toast_js = f"""
(function(){{
var old=document.getElementById('__hive_toast');if(old)old.remove();
var t=document.createElement('div');t.id='__hive_toast';
t.style.cssText='position:fixed;z-index:2147483647;top:12px;right:12px;'
+'background:rgba(30,30,30,0.9);color:#a5d6ff;font:12px/18px monospace;'
+'padding:8px 14px;border-radius:6px;max-width:420px;pointer-events:none;'
+'white-space:pre-wrap;word-break:break-all;transition:opacity 0.4s;opacity:1;'
+'border:1px solid rgba(59,130,246,0.4);box-shadow:0 4px 12px rgba(0,0,0,0.3);';
t.textContent='\\u25b6 '+'{snippet}';
document.documentElement.appendChild(t);
setTimeout(function(){{t.style.opacity='0';}},2000);
setTimeout(function(){{t.remove();}},2500);
}})();
"""
try:
await bridge.evaluate(target_tab, toast_js)
except Exception:
pass
result = await bridge.evaluate(target_tab, script)
return result
except Exception as e:
+1
View File
@@ -245,6 +245,7 @@ def register_lifecycle_tools(mcp: FastMCP) -> None:
_contexts[profile_name] = {
"groupId": group_id,
"activeTabId": tab_id,
"_seedTabId": tab_id, # reused by first browser_open call
}
logger.info(
+7 -3
View File
@@ -128,9 +128,13 @@ def register_tab_tools(mcp: FastMCP) -> None:
return result
try:
# Create tab in the group
result = await bridge.create_tab(url=url, group_id=ctx.get("groupId"))
tab_id = result.get("tabId")
# Reuse the seed about:blank tab from context.create on first open
seed_tab = ctx.pop("_seedTabId", None)
if seed_tab is not None:
tab_id = seed_tab
else:
result = await bridge.create_tab(url=url, group_id=ctx.get("groupId"))
tab_id = result.get("tabId")
# Update active tab if not background
if not background and tab_id is not None: