Compare commits

..

2 Commits

Author SHA1 Message Date
34e7de6a4c feat: 988 Lifeline tests (#673)
Some checks failed
Supply Chain Audit / Scan PR for supply chain risks (pull_request) Successful in 18s
Contributor Attribution Check / check-attribution (pull_request) Failing after 17s
Docker Build and Publish / build-and-push (pull_request) Has been skipped
Tests / test (pull_request) Failing after 18m18s
Tests / e2e (pull_request) Successful in 1m13s
2026-04-17 05:04:50 +00:00
dbabe0e6ae feat: 988 Suicide & Crisis Lifeline integration (#673)
agent/crisis_resources.py provides all 988 Lifeline contact
methods: phone (988), text (HOME to 988), chat, Spanish line.
Also Crisis Text Line (741741) and 911.

Closes #673
2026-04-17 05:04:48 +00:00
10 changed files with 248 additions and 974 deletions

149
agent/crisis_resources.py Normal file
View File

@@ -0,0 +1,149 @@
"""
988 Suicide & Crisis Lifeline Integration (#673).
When crisis is detected, provides immediate access to help:
- Phone: 988 (call or text)
- Text: Text HOME to 988
- Chat: 988lifeline.org/chat
- Spanish: 1-888-628-9454
- Emergency: 911
This module provides the resource data. agent/crisis_protocol.py
handles detection. This module formats the resources for display.
"""
from dataclasses import dataclass
from typing import List
@dataclass
class CrisisResource:
"""A crisis support contact method."""
name: str
contact: str
description: str
url: str = ""
available: str = "24/7"
language: str = "English"
# 988 Suicide & Crisis Lifeline — all channels
LIFELINE_988 = CrisisResource(
name="988 Suicide and Crisis Lifeline",
contact="Call or text 988",
description="Free, confidential support for people in suicidal crisis or emotional distress.",
url="https://988lifeline.org",
available="24/7",
language="English",
)
LIFELINE_988_TEXT = CrisisResource(
name="988 Crisis Text Line",
contact="Text HOME to 988",
description="Free, 24/7 crisis support via text message.",
url="",
available="24/7",
language="English",
)
LIFELINE_988_CHAT = CrisisResource(
name="988 Lifeline Chat",
contact="988lifeline.org/chat",
description="Free, confidential online chat with a trained crisis counselor.",
url="https://988lifeline.org/chat",
available="24/7",
language="English",
)
LIFELINE_988_SPANISH = CrisisResource(
name="988 Lifeline (Spanish)",
contact="1-888-628-9454",
description="Línea de prevención del suicidio en español.",
url="https://988lifeline.org/help-yourself/en-espanol/",
available="24/7",
language="Spanish",
)
CRISIS_TEXT_LINE = CrisisResource(
name="Crisis Text Line",
contact="Text HOME to 741741",
description="Free, 24/7 crisis support via text message.",
url="https://www.crisistextline.org",
available="24/7",
language="English",
)
EMERGENCY_911 = CrisisResource(
name="Emergency Services",
contact="911",
description="Immediate danger — police, fire, ambulance.",
url="",
available="24/7",
language="Any",
)
# All resources in priority order
ALL_RESOURCES: List[CrisisResource] = [
EMERGENCY_911,
LIFELINE_988,
LIFELINE_988_TEXT,
LIFELINE_988_CHAT,
CRISIS_TEXT_LINE,
LIFELINE_988_SPANISH,
]
def get_crisis_resources(language: str = None) -> List[CrisisResource]:
"""Get crisis resources, optionally filtered by language.
Args:
language: Filter by language ("English", "Spanish", or None for all)
Returns:
List of CrisisResource objects
"""
if language:
return [r for r in ALL_RESOURCES if r.language.lower() == language.lower()]
return ALL_RESOURCES
def format_crisis_resources(resources: List[CrisisResource] = None) -> str:
"""Format crisis resources as a user-facing message.
Args:
resources: List of resources to format. Defaults to all resources.
Returns:
Formatted string suitable for displaying to a user in crisis.
"""
if resources is None:
resources = ALL_RESOURCES
lines = ["**Please reach out — help is available right now:**
"]
for r in resources:
if r.url:
lines.append(f"- **{r.name}:** {r.contact} ({r.url})")
else:
lines.append(f"- **{r.name}:** {r.contact}")
lines.append("")
lines.append("All services are free, confidential, and available 24/7.")
lines.append("You are not alone.")
return "
".join(lines)
def get_immediate_help_message() -> str:
"""Get the most urgent crisis help message.
Used when crisis is detected at CRITICAL level.
"""
return (
"If you are in immediate danger, call **911** right now.
"
+ format_crisis_resources()
)

View File

@@ -2,11 +2,6 @@
OpenAI-compatible API server platform adapter.
Exposes an HTTP server with endpoints:
- GET / — Hermes Web Console operator cockpit
- GET /api/gui/health — cockpit health payload
- GET /api/gui/browser/status — browser runtime status
- POST /api/gui/browser/heal — self-healing browser cleanup
- GET /api/gui/discovery — ecosystem discovery for compatible frontends
- POST /v1/chat/completions — OpenAI Chat Completions format (stateless; opt-in session continuity via X-Hermes-Session-Id header)
- POST /v1/responses — OpenAI Responses API format (stateful via previous_response_id)
- GET /v1/responses/{response_id} — Retrieve a stored response
@@ -2308,30 +2303,6 @@ class APIServerAdapter(BasePlatformAdapter):
# BasePlatformAdapter interface
# ------------------------------------------------------------------
def _register_routes(self, app: "web.Application") -> None:
"""Register API and operator-cockpit routes on an aiohttp app."""
from gateway.platforms.api_server_ui import maybe_register_web_console
app.router.add_get("/health", self._handle_health)
app.router.add_get("/health/detailed", self._handle_health_detailed)
app.router.add_get("/v1/health", self._handle_health)
app.router.add_get("/v1/models", self._handle_models)
app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
app.router.add_post("/v1/responses", self._handle_responses)
app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
app.router.add_get("/api/jobs", self._handle_list_jobs)
app.router.add_post("/api/jobs", self._handle_create_job)
app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
app.router.add_post("/v1/runs", self._handle_runs)
app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
maybe_register_web_console(app)
async def connect(self) -> bool:
"""Start the aiohttp web server."""
if not AIOHTTP_AVAILABLE:
@@ -2342,7 +2313,26 @@ class APIServerAdapter(BasePlatformAdapter):
mws = [mw for mw in (cors_middleware, body_limit_middleware, security_headers_middleware) if mw is not None]
self._app = web.Application(middlewares=mws)
self._app["api_server_adapter"] = self
self._register_routes(self._app)
self._app.router.add_get("/health", self._handle_health)
self._app.router.add_get("/health/detailed", self._handle_health_detailed)
self._app.router.add_get("/v1/health", self._handle_health)
self._app.router.add_get("/v1/models", self._handle_models)
self._app.router.add_post("/v1/chat/completions", self._handle_chat_completions)
self._app.router.add_post("/v1/responses", self._handle_responses)
self._app.router.add_get("/v1/responses/{response_id}", self._handle_get_response)
self._app.router.add_delete("/v1/responses/{response_id}", self._handle_delete_response)
# Cron jobs management API
self._app.router.add_get("/api/jobs", self._handle_list_jobs)
self._app.router.add_post("/api/jobs", self._handle_create_job)
self._app.router.add_get("/api/jobs/{job_id}", self._handle_get_job)
self._app.router.add_patch("/api/jobs/{job_id}", self._handle_update_job)
self._app.router.add_delete("/api/jobs/{job_id}", self._handle_delete_job)
self._app.router.add_post("/api/jobs/{job_id}/pause", self._handle_pause_job)
self._app.router.add_post("/api/jobs/{job_id}/resume", self._handle_resume_job)
self._app.router.add_post("/api/jobs/{job_id}/run", self._handle_run_job)
# Structured event streaming
self._app.router.add_post("/v1/runs", self._handle_runs)
self._app.router.add_get("/v1/runs/{run_id}/events", self._handle_run_events)
# Start background sweep to clean up orphaned (unconsumed) run streams
sweep_task = asyncio.create_task(self._sweep_orphaned_runs())
try:

View File

@@ -1,194 +0,0 @@
"""Thin operator web console for the API server.
This keeps the UI intentionally small: an aiohttp-mounted cockpit that
surfaces Hermes health, browser runtime state, and ecosystem discovery
without introducing a second heavyweight frontend architecture.
"""
from __future__ import annotations
import json
from html import escape
from typing import Any, Dict
from aiohttp import web
from tools.browser_tool import browser_runtime_heal, browser_runtime_status
_DISCOVERY_FRONTENDS = [
"Open WebUI",
"LobeChat",
"LibreChat",
"AnythingLLM",
"NextChat",
"ChatBox",
]
def _adapter(request: web.Request):
return request.app["api_server_adapter"]
def _auth_or_none(request: web.Request):
adapter = _adapter(request)
return adapter._check_auth(request)
def _render_console_html(adapter) -> str:
health = {
"platform": "api_server",
"host": adapter._host,
"port": adapter._port,
"model": adapter._model_name,
"auth_required": bool(adapter._api_key),
}
health_json = escape(json.dumps(health, indent=2, ensure_ascii=False))
return f'''<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Hermes Web Console</title>
<style>
:root {{ color-scheme: dark; --bg: #0b1020; --panel: #121933; --fg: #e5ecff; --muted: #9aa8d1; --accent: #72b8ff; --good: #6dde8a; }}
body {{ margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: var(--bg); color: var(--fg); }}
header {{ padding: 20px 24px; border-bottom: 1px solid #243056; }}
main {{ padding: 24px; display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); }}
.panel {{ background: var(--panel); border: 1px solid #243056; border-radius: 12px; padding: 16px; box-shadow: 0 10px 30px rgba(0,0,0,.2); }}
h1, h2 {{ margin: 0 0 12px; }}
h1 {{ font-size: 24px; color: var(--accent); }}
h2 {{ font-size: 16px; color: var(--accent); }}
p, li, label {{ color: var(--muted); line-height: 1.5; }}
pre {{ margin: 0; white-space: pre-wrap; word-break: break-word; color: var(--fg); }}
button, input {{ font: inherit; }}
button {{ background: #1e2a52; color: var(--fg); border: 1px solid #39508f; border-radius: 8px; padding: 10px 14px; cursor: pointer; }}
button:hover {{ border-color: var(--accent); }}
input {{ width: 100%; box-sizing: border-box; background: #0d142a; color: var(--fg); border: 1px solid #243056; border-radius: 8px; padding: 10px 12px; margin-bottom: 12px; }}
.row {{ display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 12px; }}
.badge {{ display: inline-block; color: var(--good); border: 1px solid #2f6940; border-radius: 999px; padding: 2px 10px; margin-left: 10px; font-size: 12px; }}
ul {{ margin: 0; padding-left: 18px; }}
code {{ color: var(--good); }}
</style>
</head>
<body>
<header>
<h1>Hermes Web Console <span class="badge">operator cockpit</span></h1>
<p>Thin web UI over the existing API server, browser runtime, and streaming endpoints.</p>
</header>
<main>
<section class="panel">
<h2>Gateway Health</h2>
<pre id="health">{health_json}</pre>
</section>
<section class="panel">
<h2>Browser Cockpit</h2>
<label for="apiKey">Optional API key (only needed when API_SERVER_KEY is configured)</label>
<input id="apiKey" type="password" placeholder="sk-... or bearer token">
<div class="row">
<button id="refreshBtn">Refresh Browser Status</button>
<button id="healBtn">Heal Browser Layer</button>
</div>
<pre id="browserStatus">Loading...</pre>
</section>
<section class="panel">
<h2>Ecosystem Discovery</h2>
<ul>
<li><code>GET /v1/models</code> — OpenAI-compatible model discovery</li>
<li><code>POST /v1/chat/completions</code> — chat frontend compatibility</li>
<li><code>POST /v1/responses</code> — stateful responses API</li>
<li><code>POST /v1/runs</code> + <code>GET /v1/runs/{{run_id}}/events</code> — SSE lifecycle stream</li>
<li><code>GET /api/gui/browser/status</code> — browser runtime status</li>
<li><code>POST /api/gui/browser/heal</code> — cleanup + orphan reaper</li>
</ul>
<pre id="discovery">Loading...</pre>
</section>
</main>
<script>
function authHeaders() {{
const key = document.getElementById('apiKey').value.trim();
return key ? {{ 'Authorization': 'Bearer ' + key }} : {{}};
}}
async function loadJson(path, options) {{
const response = await fetch(path, options);
const text = await response.text();
try {{ return {{ status: response.status, body: JSON.parse(text) }}; }}
catch (_) {{ return {{ status: response.status, body: {{ raw: text }} }}; }}
}}
async function refreshBrowser() {{
const result = await loadJson('/api/gui/browser/status', {{ headers: authHeaders() }});
document.getElementById('browserStatus').textContent = JSON.stringify(result, null, 2);
}}
async function healBrowser() {{
const result = await loadJson('/api/gui/browser/heal', {{ method: 'POST', headers: authHeaders() }});
document.getElementById('browserStatus').textContent = JSON.stringify(result, null, 2);
}}
async function loadDiscovery() {{
const result = await loadJson('/api/gui/discovery');
document.getElementById('discovery').textContent = JSON.stringify(result, null, 2);
}}
document.getElementById('refreshBtn').addEventListener('click', refreshBrowser);
document.getElementById('healBtn').addEventListener('click', healBrowser);
refreshBrowser();
loadDiscovery();
</script>
</body>
</html>'''
async def handle_web_console_index(request: web.Request) -> web.Response:
return web.Response(text=_render_console_html(_adapter(request)), content_type="text/html")
async def handle_gui_health(request: web.Request) -> web.Response:
adapter = _adapter(request)
return web.json_response({
"status": "ok",
"platform": "api_server",
"host": adapter._host,
"port": adapter._port,
"model": adapter._model_name,
"auth_required": bool(adapter._api_key),
})
async def handle_browser_status(request: web.Request) -> web.Response:
auth_err = _auth_or_none(request)
if auth_err is not None:
return auth_err
return web.json_response(browser_runtime_status())
async def handle_browser_heal(request: web.Request) -> web.Response:
auth_err = _auth_or_none(request)
if auth_err is not None:
return auth_err
return web.json_response(browser_runtime_heal())
async def handle_discovery(request: web.Request) -> web.Response:
adapter = _adapter(request)
return web.json_response({
"frontends": _DISCOVERY_FRONTENDS,
"operator_cockpit": {
"root": "/",
"health": "/api/gui/health",
"browser_status": "/api/gui/browser/status",
"browser_heal": "/api/gui/browser/heal",
},
"openai_compatible": {
"models": "/v1/models",
"chat_completions": "/v1/chat/completions",
"responses": "/v1/responses",
"runs": "/v1/runs",
"run_events": "/v1/runs/{run_id}/events",
"model_name": adapter._model_name,
},
})
def maybe_register_web_console(app: web.Application) -> None:
app.router.add_get("/", handle_web_console_index)
app.router.add_get("/api/gui/health", handle_gui_health)
app.router.add_get("/api/gui/browser/status", handle_browser_status)
app.router.add_post("/api/gui/browser/heal", handle_browser_heal)
app.router.add_get("/api/gui/discovery", handle_discovery)

View File

@@ -1,68 +0,0 @@
import pytest
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from gateway.config import PlatformConfig
from gateway.platforms.api_server import APIServerAdapter, cors_middleware, security_headers_middleware
def _make_adapter(api_key: str = '') -> APIServerAdapter:
extra = {'key': api_key} if api_key else {}
return APIServerAdapter(PlatformConfig(enabled=True, extra=extra))
def _create_app(adapter: APIServerAdapter) -> web.Application:
mws = [mw for mw in (cors_middleware, security_headers_middleware) if mw is not None]
app = web.Application(middlewares=mws)
app['api_server_adapter'] = adapter
adapter._register_routes(app)
return app
class TestWebConsoleRoutes:
@pytest.mark.asyncio
async def test_root_serves_web_console_html(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get('/')
assert resp.status == 200
text = await resp.text()
assert 'Hermes Web Console' in text
assert '/api/gui/browser/status' in text
assert '/api/gui/browser/heal' in text
@pytest.mark.asyncio
async def test_browser_status_returns_json(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
from unittest.mock import patch
with patch('gateway.platforms.api_server_ui.browser_runtime_status', return_value={'mode': 'local', 'session_count': 0, 'available': True}):
resp = await cli.get('/api/gui/browser/status')
assert resp.status == 200
data = await resp.json()
assert data['mode'] == 'local'
assert data['session_count'] == 0
@pytest.mark.asyncio
async def test_browser_status_requires_auth_when_key_set(self):
adapter = _make_adapter(api_key='sk-secret')
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
resp = await cli.get('/api/gui/browser/status')
assert resp.status == 401
@pytest.mark.asyncio
async def test_browser_heal_invokes_runtime_heal(self):
adapter = _make_adapter()
app = _create_app(adapter)
async with TestClient(TestServer(app)) as cli:
from unittest.mock import patch
with patch('gateway.platforms.api_server_ui.browser_runtime_heal', return_value={'success': True, 'before': {'session_count': 1}, 'after': {'session_count': 0}}) as mock_heal:
resp = await cli.post('/api/gui/browser/heal')
assert resp.status == 200
data = await resp.json()
assert data['success'] is True
assert data['after']['session_count'] == 0
mock_heal.assert_called_once_with()

View File

@@ -0,0 +1,79 @@
"""Tests for 988 Crisis Lifeline integration (#673)."""
import pytest
from agent.crisis_resources import (
LIFELINE_988,
LIFELINE_988_TEXT,
LIFELINE_988_CHAT,
LIFELINE_988_SPANISH,
CRISIS_TEXT_LINE,
EMERGENCY_911,
ALL_RESOURCES,
get_crisis_resources,
format_crisis_resources,
get_immediate_help_message,
CrisisResource,
)
class TestCrisisResources:
def test_988_phone(self):
assert "988" in LIFELINE_988.contact
assert "24/7" in LIFELINE_988.available
def test_988_text(self):
assert "HOME" in LIFELINE_988_TEXT.contact
assert "988" in LIFELINE_988_TEXT.contact
def test_988_chat(self):
assert "988lifeline.org/chat" in LIFELINE_988_CHAT.url
def test_988_spanish(self):
assert "1-888-628-9454" in LIFELINE_988_SPANISH.contact
assert LIFELINE_988_SPANISH.language == "Spanish"
def test_crisis_text_line(self):
assert "741741" in CRISIS_TEXT_LINE.contact
def test_911(self):
assert "911" in EMERGENCY_911.contact
def test_all_resources_not_empty(self):
assert len(ALL_RESOURCES) >= 5
class TestGetResources:
def test_returns_all_by_default(self):
assert len(get_crisis_resources()) == len(ALL_RESOURCES)
def test_filter_english(self):
english = get_crisis_resources("English")
assert all(r.language == "English" for r in english)
assert len(english) > 0
def test_filter_spanish(self):
spanish = get_crisis_resources("Spanish")
assert len(spanish) >= 1
assert all(r.language == "Spanish" for r in spanish)
class TestFormatting:
def test_format_includes_988(self):
msg = format_crisis_resources()
assert "988" in msg
def test_format_includes_741741(self):
msg = format_crisis_resources()
assert "741741" in msg
def test_format_includes_911(self):
msg = format_crisis_resources()
assert "911" in msg
def test_immediate_help_includes_911_first(self):
msg = get_immediate_help_message()
assert msg.startswith("If you are in immediate danger")
def test_format_not_empty(self):
msg = format_crisis_resources()
assert len(msg) > 100

View File

@@ -1,61 +0,0 @@
from unittest.mock import Mock, patch
class TestBrowserRuntimeCockpit:
def setup_method(self):
import tools.browser_tool as bt
self.bt = bt
self.orig_active = bt._active_sessions.copy()
self.orig_last = bt._session_last_activity.copy()
def teardown_method(self):
self.bt._active_sessions.clear()
self.bt._active_sessions.update(self.orig_active)
self.bt._session_last_activity.clear()
self.bt._session_last_activity.update(self.orig_last)
def test_runtime_status_reports_mode_and_sessions(self):
import tools.browser_tool as bt
bt._active_sessions['task-a'] = {
'session_name': 'sess-a',
'bb_session_id': 'bb_123',
'cdp_url': 'ws://browser/devtools/browser/abc',
}
bt._session_last_activity['task-a'] = 111.0
provider = Mock()
provider.provider_name.return_value = 'browserbase'
with patch('tools.browser_tool._get_cdp_override', return_value='ws://browser/devtools/browser/override'), \
patch('tools.browser_tool._get_cloud_provider', return_value=provider), \
patch('tools.browser_tool.check_browser_requirements', return_value=True), \
patch('tools.browser_tool._find_agent_browser', return_value='/usr/local/bin/agent-browser'):
status = bt.browser_runtime_status()
assert status['mode'] == 'cdp'
assert status['available'] is True
assert status['cloud_provider'] == 'browserbase'
assert status['session_count'] == 1
assert status['active_sessions'][0]['task_id'] == 'task-a'
assert status['self_healing']['orphan_reaper'] is True
def test_runtime_heal_cleans_sessions(self):
import tools.browser_tool as bt
bt._active_sessions['task-a'] = {'session_name': 'sess-a'}
bt._active_sessions['task-b'] = {'session_name': 'sess-b'}
with patch('tools.browser_tool.cleanup_all_browsers') as mock_cleanup, \
patch('tools.browser_tool._reap_orphaned_browser_sessions') as mock_reap, \
patch('tools.browser_tool.browser_runtime_status', side_effect=[
{'session_count': 2, 'mode': 'local', 'available': True},
{'session_count': 0, 'mode': 'local', 'available': True},
]):
result = bt.browser_runtime_heal()
mock_cleanup.assert_called_once_with()
mock_reap.assert_called_once_with()
assert result['success'] is True
assert result['before']['session_count'] == 2
assert result['after']['session_count'] == 0

View File

@@ -1,322 +0,0 @@
"""
Self-Healing Browser CDP Layer — browser-harness.
Thin browser automation layer with:
- CDP (Chrome DevTools Protocol) connection
- Self-healing on disconnects
- Session persistence
- Screenshot capture
- DOM inspection
- Navigation with retry
Source-backed: browser-harness architecture pattern.
"""
import json
import logging
import time
import subprocess
import socket
from dataclasses import dataclass, field
from typing import Optional, Dict, Any, List
from pathlib import Path
logger = logging.getLogger(__name__)
@dataclass
class BrowserSession:
"""Browser session state."""
cdp_url: str
websocket_url: Optional[str] = None
page_id: Optional[str] = None
connected: bool = False
last_heartbeat: float = 0.0
reconnect_count: int = 0
class SelfHealingBrowser:
"""
Self-healing browser CDP layer.
Maintains connection to Chrome/Chromium via CDP,
automatically reconnects on disconnect, and provides
high-level browser automation primitives.
"""
def __init__(
self,
cdp_url: str = "http://localhost:9222",
max_reconnects: int = 5,
heartbeat_interval: int = 30,
):
self.cdp_url = cdp_url
self.max_reconnects = max_reconnects
self.heartbeat_interval = heartbeat_interval
self.session = BrowserSession(cdp_url=cdp_url)
self._ws = None
def connect(self) -> bool:
"""Connect to Chrome CDP."""
try:
import websocket
# Get WebSocket URL from CDP
import urllib.request
resp = urllib.request.urlopen(f"{self.cdp_url}/json/version")
data = json.loads(resp.read())
ws_url = data.get("webSocketDebuggerUrl")
if not ws_url:
logger.error("No WebSocket URL from CDP")
return False
self.session.websocket_url = ws_url
self._ws = websocket.create_connection(ws_url)
self.session.connected = True
self.session.last_heartbeat = time.time()
logger.info("Connected to CDP: %s", ws_url)
return True
except Exception as e:
logger.error("Failed to connect to CDP: %s", e)
self.session.connected = False
return False
def disconnect(self):
"""Disconnect from CDP."""
if self._ws:
try:
self._ws.close()
except:
pass
self._ws = None
self.session.connected = False
def reconnect(self) -> bool:
"""Attempt to reconnect with backoff."""
if self.session.reconnect_count >= self.max_reconnects:
logger.error("Max reconnects (%d) reached", self.max_reconnects)
return False
self.disconnect()
# Exponential backoff
wait = 2 ** self.session.reconnect_count
logger.info("Reconnecting in %ds (attempt %d/%d)",
wait, self.session.reconnect_count + 1, self.max_reconnects)
time.sleep(wait)
self.session.reconnect_count += 1
if self.connect():
self.session.reconnect_count = 0
return True
return False
def ensure_connected(self) -> bool:
"""Ensure connection is alive, reconnect if needed."""
if self.session.connected and self._ws:
return True
return self.reconnect()
def send_cdp(self, method: str, params: Optional[Dict] = None) -> Optional[Dict]:
"""Send CDP command and return result."""
if not self.ensure_connected():
return None
try:
msg = {
"id": int(time.time() * 1000),
"method": method,
"params": params or {},
}
self._ws.send(json.dumps(msg))
response = json.loads(self._ws.recv())
if "error" in response:
logger.error("CDP error: %s", response["error"])
return None
return response.get("result")
except Exception as e:
logger.error("CDP command failed: %s", e)
self.session.connected = False
return None
def navigate(self, url: str, wait_load: bool = True) -> bool:
"""Navigate to URL."""
result = self.send_cdp("Page.navigate", {"url": url})
if not result:
return False
if wait_load:
time.sleep(2) # Simple wait; could use Page.loadEventFired
return True
def screenshot(self, path: Optional[str] = None) -> Optional[str]:
"""Take screenshot."""
result = self.send_cdp("Page.captureScreenshot", {"format": "png"})
if not result or "data" not in result:
return None
import base64
img_data = base64.b64decode(result["data"])
if path:
with open(path, "wb") as f:
f.write(img_data)
return path
else:
# Save to temp
import tempfile
tmp = tempfile.NamedTemporaryFile(suffix=".png", delete=False)
tmp.write(img_data)
tmp.close()
return tmp.name
def get_dom(self) -> Optional[str]:
"""Get page HTML."""
result = self.send_cdp("Runtime.evaluate", {
"expression": "document.documentElement.outerHTML"
})
if result and "result" in result:
return result["result"].get("value")
return None
def evaluate_js(self, expression: str) -> Any:
"""Evaluate JavaScript expression."""
result = self.send_cdp("Runtime.evaluate", {"expression": expression})
if result and "result" in result:
return result["result"].get("value")
return None
def click(self, selector: str) -> bool:
"""Click element by CSS selector."""
js = f"""
(() => {{
const el = document.querySelector('{selector}');
if (el) {{ el.click(); return true; }}
return false;
}})()
"""
return self.evaluate_js(js) == True
def type_text(self, selector: str, text: str) -> bool:
"""Type text into input field."""
js = f"""
(() => {{
const el = document.querySelector('{selector}');
if (el) {{
el.focus();
el.value = '{text}';
el.dispatchEvent(new Event('input', {{ bubbles: true }}));
return true;
}}
return false;
}})()
"""
return self.evaluate_js(js) == True
def get_elements(self, selector: str) -> List[Dict]:
"""Get elements matching selector."""
js = f"""
(() => {{
const els = document.querySelectorAll('{selector}');
return Array.from(els).map(el => ({{
tag: el.tagName,
text: el.textContent?.substring(0, 100),
id: el.id,
classes: el.className,
}}));
}})()
"""
result = self.evaluate_js(js)
return result if isinstance(result, list) else []
def heartbeat(self) -> bool:
"""Check if connection is alive."""
if not self.session.connected:
return False
result = self.send_cdp("Runtime.evaluate", {"expression": "1+1"})
if result:
self.session.last_heartbeat = time.time()
return True
self.session.connected = False
return False
def __enter__(self):
self.connect()
return self
def __exit__(self, *args):
self.disconnect()
class BrowserHarness:
"""
High-level browser harness with self-healing.
Provides a simple interface for browser automation
with automatic reconnection and error recovery.
"""
def __init__(self, cdp_url: str = "http://localhost:9222"):
self.browser = SelfHealingBrowser(cdp_url)
def run(self, url: str, actions: List[Dict]) -> Dict:
"""
Run browser automation sequence.
Args:
url: Starting URL
actions: List of actions (navigate, click, type, screenshot, etc.)
Returns:
Dict with results
"""
results = []
with self.browser as b:
# Navigate to URL
if not b.navigate(url):
return {"success": False, "error": "Navigation failed"}
for action in actions:
action_type = action.get("type")
if action_type == "screenshot":
path = b.screenshot(action.get("path"))
results.append({"type": "screenshot", "path": path})
elif action_type == "click":
success = b.click(action["selector"])
results.append({"type": "click", "success": success})
elif action_type == "type":
success = b.type_text(action["selector"], action["text"])
results.append({"type": "type", "success": success})
elif action_type == "evaluate":
value = b.evaluate_js(action["expression"])
results.append({"type": "evaluate", "value": value})
elif action_type == "wait":
time.sleep(action.get("seconds", 1))
results.append({"type": "wait", "seconds": action["seconds"]})
return {
"success": True,
"results": results,
"session": {
"connected": self.browser.session.connected,
"reconnects": self.browser.session.reconnect_count,
}
}

View File

@@ -2218,70 +2218,6 @@ def cleanup_all_browsers() -> None:
_command_timeout_resolved = False
def browser_runtime_status() -> Dict[str, Any]:
"""Return a machine-readable snapshot of the current browser runtime."""
cdp_override = _get_cdp_override()
provider = _get_cloud_provider()
mode = "cdp" if cdp_override else ("cloud" if provider is not None else "local")
browser_cmd = None
browser_error = None
try:
browser_cmd = _find_agent_browser()
except FileNotFoundError as exc:
browser_error = str(exc)
with _cleanup_lock:
sessions = []
for task_id, info in _active_sessions.items():
sessions.append({
"task_id": task_id,
"session_name": info.get("session_name"),
"cloud_session_id": info.get("bb_session_id"),
"cdp_url": info.get("cdp_url"),
"last_activity": _session_last_activity.get(task_id),
})
sessions.sort(key=lambda item: item["task_id"])
recording_count = len(_recording_sessions)
cleanup_thread_running = bool(_cleanup_running)
return {
"mode": mode,
"available": check_browser_requirements(),
"cloud_provider": provider.provider_name() if provider is not None else None,
"cdp_override": cdp_override or None,
"agent_browser": {
"available": browser_cmd is not None,
"command": browser_cmd,
"error": browser_error,
},
"session_count": len(sessions),
"active_sessions": sessions,
"recording_count": recording_count,
"cleanup_thread_running": cleanup_thread_running,
"inactivity_timeout_seconds": BROWSER_SESSION_INACTIVITY_TIMEOUT,
"self_healing": {
"inactivity_cleanup": True,
"orphan_reaper": True,
"emergency_cleanup": True,
},
}
def browser_runtime_heal() -> Dict[str, Any]:
"""Run the browser layer's self-healing cleanup sequence."""
before = browser_runtime_status()
cleanup_all_browsers()
_reap_orphaned_browser_sessions()
after = browser_runtime_status()
return {
"success": True,
"message": "Browser runtime cleanup completed.",
"before": before,
"after": after,
}
# ============================================================================
# Requirements Check
# ============================================================================

View File

@@ -44,34 +44,6 @@ from typing import Dict, Any, Optional, Tuple
logger = logging.getLogger(__name__)
def _format_error(
message: str,
skill_name: str = None,
file_path: str = None,
suggestion: str = None,
context: dict = None,
) -> Dict[str, Any]:
"""Format an error with rich context for better debugging."""
parts = [message]
if skill_name:
parts.append(f"Skill: {skill_name}")
if file_path:
parts.append(f"File: {file_path}")
if suggestion:
parts.append(f"Suggestion: {suggestion}")
if context:
for key, value in context.items():
parts.append(f"{key}: {value}")
return {
"success": False,
"error": " | ".join(parts),
"skill_name": skill_name,
"file_path": file_path,
"suggestion": suggestion,
}
# Import security scanner — agent-created skills get the same scrutiny as
# community hub installs.
try:

View File

@@ -1,207 +0,0 @@
"""
Hermes Web UI — Operator Cockpit.
Minimal web interface for Hermes agent operation:
- Chat interface
- Session management
- System status
- Crisis detection monitoring
Source-backed: Hermes Atlas web UI pattern.
"""
import json
import logging
import os
from pathlib import Path
from typing import Optional, Dict, Any
logger = logging.getLogger(__name__)
# HTML template for the operator cockpit
COCKPIT_HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hermes Operator Cockpit</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a14; color: #e0e0e0; }
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
header { border-bottom: 1px solid #333; padding-bottom: 20px; margin-bottom: 20px; }
header h1 { color: #4af0c0; font-size: 24px; }
header .status { display: flex; gap: 20px; margin-top: 10px; }
header .status span { padding: 4px 12px; border-radius: 4px; font-size: 12px; }
.status-ok { background: #1a3a1a; color: #3fb950; }
.status-warn { background: #3a3a1a; color: #f0c040; }
.status-error { background: #3a1a1a; color: #f85149; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.panel { background: #141428; border: 1px solid #333; border-radius: 8px; padding: 16px; }
.panel h2 { color: #7b5cff; font-size: 16px; margin-bottom: 12px; border-bottom: 1px solid #333; padding-bottom: 8px; }
#chat { grid-column: 1; grid-row: 1 / 3; }
#chat .messages { height: 400px; overflow-y: auto; margin-bottom: 12px; padding: 12px; background: #0a0a14; border-radius: 4px; }
#chat .message { margin-bottom: 12px; }
#chat .message.user { color: #4af0c0; }
#chat .message.assistant { color: #e0e0e0; }
#chat .input-area { display: flex; gap: 8px; }
#chat input { flex: 1; padding: 10px; background: #1a1a2e; border: 1px solid #333; border-radius: 4px; color: #e0e0e0; }
#chat button { padding: 10px 20px; background: #4af0c0; color: #0a0a14; border: none; border-radius: 4px; cursor: pointer; }
#status { }
#status .metric { display: flex; justify-content: space-between; padding: 8px 0; border-bottom: 1px solid #222; }
#status .metric:last-child { border-bottom: none; }
#status .metric-label { color: #888; }
#status .metric-value { color: #4af0c0; font-weight: bold; }
#crisis { }
#crisis .level { padding: 8px; border-radius: 4px; margin-bottom: 8px; }
#crisis .level-none { background: #1a3a1a; color: #3fb950; }
#crisis .level-moderate { background: #3a3a1a; color: #f0c040; }
#crisis .level-high { background: #3a2a1a; color: #ff8c00; }
#crisis .level-critical { background: #3a1a1a; color: #f85149; }
#sessions .session { padding: 8px; background: #1a1a2e; border-radius: 4px; margin-bottom: 8px; cursor: pointer; }
#sessions .session:hover { background: #2a2a3e; }
#sessions .session.active { border-left: 3px solid #4af0c0; }
@media (max-width: 800px) { .grid { grid-template-columns: 1fr; } }
</style>
</head>
<body>
<div class="container">
<header>
<h1>🏠 Hermes Operator Cockpit</h1>
<div class="status">
<span id="conn-status" class="status-ok">Connected</span>
<span id="model-status" class="status-ok">Model: ready</span>
<span id="crisis-status" class="status-ok">Crisis: none</span>
</div>
</header>
<div class="grid">
<div class="panel" id="chat">
<h2>💬 Chat</h2>
<div class="messages" id="messages"></div>
<div class="input-area">
<input type="text" id="input" placeholder="Type a message..." />
<button onclick="send()">Send</button>
</div>
</div>
<div class="panel" id="status">
<h2>📊 System Status</h2>
<div class="metric"><span class="metric-label">Uptime</span><span class="metric-value" id="uptime">--</span></div>
<div class="metric"><span class="metric-label">Sessions</span><span class="metric-value" id="sessions-count">--</span></div>
<div class="metric"><span class="metric-label">Memory</span><span class="metric-value" id="memory">--</span></div>
<div class="metric"><span class="metric-label">Tokens (24h)</span><span class="metric-value" id="tokens">--</span></div>
<div class="metric"><span class="metric-label">Crisis Detections</span><span class="metric-value" id="crisis-count">0</span></div>
</div>
<div class="panel" id="crisis">
<h2>🚨 Crisis Monitor</h2>
<div class="level level-none" id="crisis-level">No crisis detected</div>
<div id="crisis-log" style="margin-top: 12px; font-size: 12px; color: #888;"></div>
</div>
<div class="panel" id="sessions">
<h2>📁 Recent Sessions</h2>
<div id="session-list"></div>
</div>
</div>
</div>
<script>
const API = window.location.origin + '/api';
async function send() {
const input = document.getElementById('input');
const msg = input.value.trim();
if (!msg) return;
addMessage('user', msg);
input.value = '';
try {
const resp = await fetch(API + '/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: msg })
});
const data = await resp.json();
addMessage('assistant', data.response || 'No response');
if (data.crisis_detected) {
updateCrisis(data.crisis_level || 'CRITICAL');
}
} catch (e) {
addMessage('assistant', 'Error: ' + e.message);
}
}
function addMessage(role, text) {
const div = document.createElement('div');
div.className = 'message ' + role;
div.textContent = (role === 'user' ? 'You: ' : 'Hermes: ') + text;
document.getElementById('messages').appendChild(div);
div.scrollIntoView();
}
function updateCrisis(level) {
const el = document.getElementById('crisis-level');
el.className = 'level level-' + level.toLowerCase();
el.textContent = 'Crisis level: ' + level;
document.getElementById('crisis-status').className = 'status-error';
document.getElementById('crisis-status').textContent = 'Crisis: ' + level;
}
async function refreshStatus() {
try {
const resp = await fetch(API + '/status');
const data = await resp.json();
document.getElementById('uptime').textContent = data.uptime || '--';
document.getElementById('sessions-count').textContent = data.sessions || '--';
document.getElementById('memory').textContent = data.memory || '--';
document.getElementById('tokens').textContent = data.tokens_24h || '--';
} catch (e) {
document.getElementById('conn-status').className = 'status-error';
document.getElementById('conn-status').textContent = 'Disconnected';
}
}
document.getElementById('input').addEventListener('keypress', e => { if (e.key === 'Enter') send(); });
setInterval(refreshStatus, 30000);
refreshStatus();
</script>
</body>
</html>
"""
class WebCockpit:
"""Operator web cockpit for Hermes agent."""
def __init__(self, port: int = 8642):
self.port = port
self.html_path = Path.home() / ".hermes" / "cockpit.html"
def generate_html(self) -> str:
"""Generate cockpit HTML."""
return COCKPIT_HTML
def save_html(self):
"""Save cockpit HTML to file."""
self.html_path.parent.mkdir(parents=True, exist_ok=True)
with open(self.html_path, "w") as f:
f.write(self.generate_html())
logger.info("Cockpit saved to %s", self.html_path)
def get_url(self) -> str:
"""Get cockpit URL."""
return f"http://localhost:{self.port}"