#!/usr/bin/env python3 """Let the agent react to a message with an emoji in the Hermes desktop app. The conversational counterpart to the user's tapback: the same reaction store, the same one-per-author semantics, just written with ``author="agent"``. Gated on ``HERMES_DESKTOP`` (like the other GUI affordances) so it costs nothing on every other surface — the platform adapters already expose reactions through ``send_message(action="react")``, and this is the desktop's equivalent. Defaults to the message that triggered this turn (the photon precedent: the model shouldn't have to thread row ids through tool calls), and emits ``message.reaction`` so the renderer paints it without waiting for a resume. """ import json from gateway.session_context import get_session_env from tools import desktop_ui from tools.registry import registry, tool_error from utils import env_var_enabled def _open_session_db(): """Open the SessionDB for the profile owning this turn, or ``None``.""" try: from hermes_state import SessionDB return SessionDB() except Exception: return None def react_to_message_tool(emoji: str, message_row_id=None, messages_back=None) -> str: """Attach (or with an empty ``emoji`` retract) the agent's reaction.""" emoji = (emoji or "").strip() session_key = get_session_env("HERMES_SESSION_KEY", "") or get_session_env( "HERMES_SESSION_ID", "" ) if not session_key: return tool_error("No active session — reactions need a persisted conversation.") db = _open_session_db() if db is None: return tool_error("Session storage is unavailable.") row_id = message_row_id target_role = "user" if row_id is None: # Default target: the latest user message. `messages_back` steps to # earlier user turns (1 = the one before, etc.) for retroactive # reactions — quoting text would be ambiguous, ids aren't visible to # the model, but "two messages ago" is how a person thinks about it. back = max(0, int(messages_back or 0)) row_id = db.latest_message_row_id(session_key, role="user", offset=back) if row_id is None: return tool_error( f"No user message found {back} back." if back else "No user message to react to yet." ) else: row = db.get_message_role(session_key, int(row_id)) target_role = row or "user" try: reactions = db.set_message_reaction( session_key, int(row_id), emoji or None, author="agent" ) except Exception as exc: return tool_error(f"Failed to set the reaction: {exc}") if reactions is None: return tool_error(f"Message {row_id} is not part of this conversation.") # Paint it live. A missing bridge (non-desktop surface) is not an error — # the reaction is persisted either way and shows on the next load. # `role` lets the renderer match a live message that doesn't know its # durable row id yet (it only learns rowId on resume). try: desktop_ui.emit( "message.reaction", {"row_id": int(row_id), "reactions": reactions, "role": target_role}, ) except Exception: pass return json.dumps( {"success": True, "row_id": int(row_id), "reactions": reactions}, ensure_ascii=False ) def check_react_requirements() -> bool: """Desktop GUI only, and opt-in. HERMES_DESKTOP is set on the gateway the app spawns; the feature itself is off by default and enabled from Settings → Appearance (the desktop mirrors the toggle into ``display.message_reactions``). """ if not env_var_enabled("HERMES_DESKTOP"): return False try: from hermes_cli.config import load_config_readonly display = load_config_readonly().get("display") except Exception: return False return isinstance(display, dict) and bool(display.get("message_reactions", False)) REACT_TO_MESSAGE_SCHEMA = { "name": "react_to_message", "description": ( "React to a message with a single emoji, the way you'd tapback in iMessage. " "Reach for it when a reaction is what a person would do: something funny gets " "a 😂, warmth gets a ❤️, a plan you're on board with gets a 👍 — then just " "carry on with whatever the message actually needs. If a reaction says it " "all, it can BE the reply (skip the redundant 'sounds good!' turn). Use it " "like a person would: occasionally, when felt — not on every message, and " "never as a status signal. NEVER narrate or explain a reaction ('I reacted " "with...', 'Reacting now') — the emoji appearing on the bubble is the whole " "point, and commentary kills it. Defaults to the user's most recent message. " "One reaction per message: a different emoji replaces yours, an empty string " "retracts it." ), "parameters": { "type": "object", "properties": { "emoji": { "type": "string", "description": ( "The emoji to react with (e.g. '❤️', '😂', '👍'). Pass an empty " "string to remove your reaction." ), }, "message_row_id": { "type": "integer", "description": ( "Optional. The specific message to react to. Omit to react to the " "user's latest message, which is almost always what you want." ), }, "messages_back": { "type": "integer", "description": ( "Optional. React to an EARLIER user message: 1 = the one before " "the latest, 2 = two before, and so on. For when something lands " "late — the joke you only got after answering." ), }, }, "required": ["emoji"], }, } registry.register( name="react_to_message", toolset="terminal", schema=REACT_TO_MESSAGE_SCHEMA, handler=lambda args, **kw: react_to_message_tool( emoji=args.get("emoji", ""), message_row_id=args.get("message_row_id"), messages_back=args.get("messages_back"), ), check_fn=check_react_requirements, emoji="💛", )