mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-11-06 05:17:15 +00:00
- The Desktop's canonical adapter replaced the server's open_requests with entries built from its own prompts, so a warm session.activate dropped a waiting batch clarify and restore-pending-clarify then cleared it. The snapshot now keeps the server's entries beside the projected prompts. - Automatic window requests (terminal/preview reads, preview clicks, the tour, theme.apply) are asked with replay=False: a reconnecting viewer is not handed them again, because a second delivery repeats the action. Requests that wait for a person keep the replay. - Cmd/Ctrl+Enter (queueDraft) skipped the setup slash check. Every composer send action now asks one refusedDuringSetup rule first. - Slash availability lives in the completion source: useSlashCompletions returns no adapter while setup runs, and both trigger engines (composer and message edit) open no `/` menu for a source without one. The `off` flag is gone.
290 lines
15 KiB
Python
290 lines
15 KiB
Python
"""The desktop window bridges for a local viewer's turn on the session authority.
|
|
|
|
Main's TUI sidecar wired two things per session (``tui_gateway/session_notifications.py::
|
|
_wire_desktop_sinks`` and ``tui_gateway/agent_callbacks.py::_agent_cbs``):
|
|
|
|
* the ``tools.desktop_ui`` emitter, so ``desktop_preview`` open/close, ``focus_pane``,
|
|
``close_terminal`` and the other window events reach the renderer as ``event`` frames;
|
|
* the GUI callbacks (``read_preview_callback``, ``drive_preview_callback``,
|
|
``read_terminal_callback``, ``read_window_below_callback``, ``theme_callback``, ``tour_callback``,
|
|
``setup_beat_callback``), answered by the renderer through ``preview.read`` / ``preview.act`` /
|
|
``terminal.read`` / ``window.read`` / ``theme.apply`` / ``theme.pick`` / ``tour`` /
|
|
``setup_beat`` server requests;
|
|
* the process-registry sinks (``on_output`` / ``on_close``) that stream a background process into
|
|
its read-only terminal tab (``agent.terminal.output``) and let ``close_terminal`` drop the tab
|
|
(``terminal.close``).
|
|
|
|
On the session authority none was installed, so every ``desktop_ui`` tool the session policy
|
|
still offers a GUI session failed with "only available in the Hermes desktop app". Here the
|
|
emitter and the process sinks are installed once per process; the emitter routes each event to the
|
|
turn that raised it (by the turn's bound ``HERMES_SESSION_ID``) through the generation-fenced
|
|
execution stream, and the process sinks route by the process's ``session_key`` to the owning
|
|
session's event stream (a background process outlives the turn that started it). The callbacks
|
|
are installed per turn on the cached agent and restored with the other turn sinks.
|
|
|
|
Card for ``manage_connections``: ``connection_callback`` publishes ``connection.request`` on the turn, and each later change of an operation is published as ``connection.update`` on the session that owns it (looked up by its session key, the route); the card answers through ``connection.respond`` (``gateway/session_connections.py``).
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
import threading
|
|
import time
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Agent attributes ``wire`` installs for one local-viewer turn (restored by ``local_viewer_events``).
|
|
CALLBACK_ATTRS = ("read_terminal_callback", "read_preview_callback", "drive_preview_callback",
|
|
"read_window_below_callback", "theme_callback", "tour_callback", "setup_beat_callback",
|
|
"connection_callback")
|
|
|
|
# main: tui_gateway/agent_callbacks.py ``_read_block`` budgets and tui_gateway/server.py's preview.act ladder.
|
|
_TERMINAL_READ_TIMEOUT_S = 30
|
|
_PREVIEW_READ_TIMEOUT_S = 45
|
|
_WINDOW_READ_TIMEOUT_S = 30
|
|
_PREVIEW_ACTION_TIMEOUT_S = 45
|
|
_PREVIEW_ACTION_PROBE_TIMEOUT_S = 10
|
|
_PREVIEW_ACTION_REPROBE_COOLDOWN_S = 30
|
|
_PREVIEW_ACTION_BRIDGE_UNAVAILABLE = json.dumps({
|
|
"success": False,
|
|
"error": ("No Hermes Desktop window answered the preview action request. The drive_preview / "
|
|
"annotate_preview bridge is served by the desktop app's renderer, which updates "
|
|
"separately from this backend, so an app build older than the tool has nothing "
|
|
"listening. Update the Hermes Desktop app, open a page with open_preview, and try "
|
|
"again in this session after a short cooldown.")})
|
|
|
|
# main: tui_gateway/theme_bridge.py and tui_gateway/server.py's tour ladder.
|
|
_THEME_TIMEOUT_S = 15
|
|
_THEME_PICK_TIMEOUT_S = 300
|
|
_THEME_UNANSWERED = {"success": False, "error": "No Hermes Desktop window answered the theme request."}
|
|
_TOUR_TIMEOUT_S = 45
|
|
_TOUR_PROBE_TIMEOUT_S = 10
|
|
_TOUR_BRIDGE_UNAVAILABLE = json.dumps({
|
|
"success": False,
|
|
"error": ("No Hermes Desktop window answered the tour request. The tour is driven by the desktop app's "
|
|
"renderer, which updates separately from this backend, so an app build older than the tour tool "
|
|
"has nothing listening. Update the Hermes Desktop app and start a new session. Do not retry tour "
|
|
"in this session.")})
|
|
|
|
_lock = threading.Lock()
|
|
# turn key (the turn's bound HERMES_SESSION_ID) -> publish(event_type, payload) of the live turn
|
|
_turns: dict[str, object] = {}
|
|
# canonical session id -> {"state": "answered"|"unanswered", "retry_at": monotonic}
|
|
_preview_bridge: dict[str, dict] = {}
|
|
# canonical session id -> "answered" | "unanswered" (main: the session record's ``tour_bridge``)
|
|
_tour_bridge: dict[str, str] = {}
|
|
# process ``session_key`` -> (authority, canonical session id) of the session that last ran a turn with it
|
|
_process_routes: dict[str, tuple] = {}
|
|
_emitter_installed = False
|
|
_process_sinks_installed = False
|
|
|
|
|
|
def _emit(sid, event, payload) -> None:
|
|
"""``desktop_ui`` sink: publish *event* on the stream of the turn that raised it."""
|
|
from gateway.session_context import get_session_env
|
|
keys = [str(sid or ""), get_session_env("HERMES_SESSION_ID", "")]
|
|
with _lock:
|
|
publish = next((_turns[key] for key in keys if key and key in _turns), None)
|
|
if publish is None:
|
|
# Raised outside a local-viewer turn (a messaging route, a background thread): no window.
|
|
raise RuntimeError("no Hermes desktop window is attached to this turn")
|
|
publish(event, dict(payload or {}))
|
|
|
|
|
|
def _install_emitter() -> None:
|
|
global _emitter_installed
|
|
if _emitter_installed:
|
|
return
|
|
from tools import desktop_ui
|
|
desktop_ui.set_emitter(_emit)
|
|
_emitter_installed = True
|
|
|
|
|
|
def _publish_process(session, event_type, payload) -> None:
|
|
"""Publish a process event on the stream of the session that owns the process."""
|
|
key = str(getattr(session, "session_key", "") or "") if session is not None else ""
|
|
with _lock:
|
|
route = _process_routes.get(key) if key else None
|
|
if route is None:
|
|
raise RuntimeError("no Hermes desktop window owns this process")
|
|
authority, session_id = route
|
|
live = authority.sessions.get(session_id)
|
|
if live is None:
|
|
raise RuntimeError("the session that owns this process is no longer live")
|
|
live.event_stream.publish(session_id, payload, event_type=event_type)
|
|
|
|
|
|
def _install_process_sinks() -> None:
|
|
"""main's ``_wire_desktop_sinks``: live output and tab close for background processes (once)."""
|
|
global _process_sinks_installed
|
|
if _process_sinks_installed:
|
|
return
|
|
from tools.process_registry import process_registry
|
|
if getattr(process_registry, "on_output", None) is None:
|
|
process_registry.on_output = lambda session, chunk: _publish_process(
|
|
session, "agent.terminal.output", {"process_id": session.id, "chunk": chunk})
|
|
if getattr(process_registry, "on_close", None) is None:
|
|
process_registry.on_close = lambda session, pid: _publish_process(
|
|
session, "terminal.close", {"process_id": pid})
|
|
_process_sinks_installed = True
|
|
|
|
|
|
def _install_connection_hook() -> None:
|
|
"""``connection.update`` for an operation opened by one of our turns, on the session that owns it (same session key and profile, as the sidecar matches its own); any other operation goes to the hook this one wraps (the sidecar's, for its own sessions). Re-wraps whenever another hook replaced it, so install order does not matter."""
|
|
from tools.connectors.operation import ConnectionOperation
|
|
previous = ConnectionOperation.on_change
|
|
if getattr(previous, "_local_desktop_ui", False):
|
|
return
|
|
|
|
def on_change(operation, change, snapshot):
|
|
from hermes_constants import hermes_home_key
|
|
with _lock:
|
|
route = _process_routes.get(operation.session_key)
|
|
live = route[0].sessions.get(route[1]) if route is not None else None
|
|
if live is None or hermes_home_key(route[0].profile_id) != operation.profile_key:
|
|
if previous is not None:
|
|
previous(operation, change, snapshot)
|
|
return
|
|
from tui_gateway.connector_payload import connector_ui_payload
|
|
payload = {**snapshot, "settled": snapshot.get("settled_at") is not None, **(change or {}),
|
|
"owner": {"type": "session", "session_id": route[1]}}
|
|
live.event_stream.publish(route[1], connector_ui_payload(payload), event_type="connection.update")
|
|
|
|
on_change._local_desktop_ui = True
|
|
ConnectionOperation.on_change = staticmethod(on_change)
|
|
|
|
|
|
def _ask_result(turn, agent, method, params, timeout):
|
|
"""One structured server request; the viewer's ``result`` dict, or None when none answered."""
|
|
from gateway.session_server_requests import ask_viewer_result
|
|
authority, session_id, _generation = turn._approval_owner
|
|
live = authority.sessions.get(session_id)
|
|
if live is None:
|
|
return None
|
|
return ask_viewer_result(live.event_stream, session_id, method, dict(params), timeout=timeout,
|
|
cancelled=lambda: bool(getattr(agent, "_interrupt_requested", False)))
|
|
|
|
|
|
def _theme(turn, agent, payload) -> dict:
|
|
"""main's ``theme_request``: ``theme.apply`` for values, or a ``theme.pick`` card that waits."""
|
|
if "picker" not in payload:
|
|
answer = _ask(turn, agent, "theme.apply", payload, _THEME_TIMEOUT_S)
|
|
if not answer:
|
|
return dict(_THEME_UNANSWERED)
|
|
try:
|
|
return json.loads(answer)
|
|
except ValueError:
|
|
return {"success": True, "text": answer}
|
|
started = time.monotonic()
|
|
result = _ask_result(turn, agent, "theme.pick", payload, _THEME_PICK_TIMEOUT_S)
|
|
if result is None:
|
|
return {"timed_out": True} if time.monotonic() - started >= _THEME_PICK_TIMEOUT_S else {"cancelled": True}
|
|
if isinstance(result.get("picked"), dict):
|
|
return {"success": True, "picked": result["picked"]}
|
|
if isinstance(result.get("said"), str) and result["said"].strip():
|
|
return {"said": result["said"].strip()}
|
|
return {"cancelled": True}
|
|
|
|
|
|
def _tour(turn, agent, payload) -> str:
|
|
"""main's ``_tour_request``: probe once per session, then the full deadline or fail fast."""
|
|
session_id = turn._approval_owner[1]
|
|
with _lock:
|
|
state = _tour_bridge.get(session_id)
|
|
if state == "unanswered":
|
|
return _TOUR_BRIDGE_UNAVAILABLE
|
|
answer = _ask(turn, agent, "tour", payload, _TOUR_TIMEOUT_S if state == "answered" else _TOUR_PROBE_TIMEOUT_S)
|
|
with _lock:
|
|
if answer:
|
|
_tour_bridge[session_id] = "answered"
|
|
elif state != "answered" and not getattr(agent, "_interrupt_requested", False):
|
|
_tour_bridge[session_id] = "unanswered"
|
|
return answer or _TOUR_BRIDGE_UNAVAILABLE
|
|
|
|
|
|
def _setup_beat(turn, agent, payload):
|
|
from agent.onboarding_flow import CARD_TIMEOUT_S
|
|
return _ask_result(turn, agent, "setup_beat", payload, CARD_TIMEOUT_S)
|
|
|
|
|
|
def _ask(turn, agent, method, params, timeout):
|
|
"""One ``ValueResult`` server request to the session's attached viewers; "" when none answered."""
|
|
from gateway.session_server_requests import ask_viewer
|
|
authority, session_id, _generation = turn._approval_owner
|
|
live = authority.sessions.get(session_id)
|
|
if live is None:
|
|
return ""
|
|
# Automatic window requests (reads, a preview click, the tour) are not handed to a reconnecting
|
|
# viewer again: a second delivery would repeat the action.
|
|
value = ask_viewer(live.event_stream, session_id, method, dict(params), timeout=timeout, replay=False,
|
|
cancelled=lambda: bool(getattr(agent, "_interrupt_requested", False)))
|
|
return value if isinstance(value, str) else json.dumps(value, ensure_ascii=False)
|
|
|
|
|
|
def _read_block(turn, agent, method, timeout):
|
|
return lambda start=None, count=None: _ask(
|
|
turn, agent, method, {k: v for k, v in (("start", start), ("count", count)) if v is not None}, timeout)
|
|
|
|
|
|
def _preview_action(turn, agent, payload) -> str:
|
|
"""main's ``_preview_action_request`` ladder: a short probe until the renderer has answered once,
|
|
then the full deadline; an unanswered probe fails fast for a cooldown instead of stacking waits."""
|
|
session_id = turn._approval_owner[1]
|
|
now = time.monotonic()
|
|
with _lock:
|
|
bridge = _preview_bridge.setdefault(session_id, {})
|
|
state = bridge.get("state")
|
|
if state == "unanswered" and now < bridge.get("retry_at", 0):
|
|
return _PREVIEW_ACTION_BRIDGE_UNAVAILABLE
|
|
if state == "unanswered":
|
|
bridge["retry_at"] = now + _PREVIEW_ACTION_REPROBE_COOLDOWN_S
|
|
answer = _ask(turn, agent, "preview.act", payload,
|
|
_PREVIEW_ACTION_TIMEOUT_S if state == "answered" else _PREVIEW_ACTION_PROBE_TIMEOUT_S)
|
|
with _lock:
|
|
if answer:
|
|
bridge["state"] = "answered"
|
|
elif bridge.get("state") != "answered" and not getattr(agent, "_interrupt_requested", False):
|
|
bridge["state"] = "unanswered"
|
|
bridge["retry_at"] = time.monotonic() + _PREVIEW_ACTION_REPROBE_COOLDOWN_S
|
|
return answer or _PREVIEW_ACTION_BRIDGE_UNAVAILABLE
|
|
|
|
|
|
def wire(turn, agent):
|
|
"""Install the window bridges for one local-viewer turn; returns the registry key to release."""
|
|
owner = getattr(turn, "_approval_owner", None)
|
|
if owner is None:
|
|
return None
|
|
try:
|
|
_install_emitter()
|
|
_install_process_sinks()
|
|
_install_connection_hook()
|
|
except Exception: # health: allow BLE001 -- the window bridges are optional; the turn must run
|
|
logger.warning("Desktop UI sink install failed", exc_info=True)
|
|
key = str(getattr(turn._ctx, "session_id", "") or owner[1])
|
|
publish = turn._publish_execution
|
|
process_key = str(getattr(turn._ctx, "session_key", "") or "")
|
|
with _lock:
|
|
_turns[key] = publish
|
|
if process_key:
|
|
_process_routes[process_key] = (owner[0], owner[1])
|
|
agent.read_terminal_callback = _read_block(turn, agent, "terminal.read", _TERMINAL_READ_TIMEOUT_S)
|
|
agent.read_preview_callback = _read_block(turn, agent, "preview.read", _PREVIEW_READ_TIMEOUT_S)
|
|
agent.drive_preview_callback = lambda payload: _preview_action(turn, agent, dict(payload or {}))
|
|
agent.read_window_below_callback = lambda: _ask(turn, agent, "window.read", {}, _WINDOW_READ_TIMEOUT_S)
|
|
agent.theme_callback = lambda payload: _theme(turn, agent, dict(payload or {}))
|
|
agent.tour_callback = lambda payload: _tour(turn, agent, dict(payload or {}))
|
|
agent.setup_beat_callback = lambda payload: _setup_beat(turn, agent, dict(payload or {}))
|
|
# Fire-and-forget: the tool thread waits on its own operation; the card answers by op_id.
|
|
agent.connection_callback = lambda payload: publish("connection.request", dict(payload)) and None
|
|
return key, publish
|
|
|
|
|
|
def release(token) -> None:
|
|
"""Drop this turn's routing entry, unless a newer turn of the same session already replaced it."""
|
|
if not token:
|
|
return
|
|
key, publish = token
|
|
with _lock:
|
|
if _turns.get(key) is publish:
|
|
_turns.pop(key, None)
|